DevToolBoxGRATIS
Blog

OAuth2 en OpenID Connect: Implementatie Gids

14 minby DevToolBox

OAuth 2.0 and OpenID Connect: The Complete Implementation Guide

OAuth 2.0 is the industry standard for authorization — it lets users grant third-party applications limited access to their resources without sharing passwords. OpenID Connect (OIDC) builds on top of OAuth 2.0 to add authentication — verifying who the user is. This guide covers both protocols, their flows, token types, and practical implementation with Node.js and TypeScript.

OAuth 2.0 vs OpenID Connect

AspectOAuth 2.0OpenID Connect
PurposeAuthorization (what can you access?)Authentication (who are you?)
TokenAccess TokenID Token + Access Token
Token formatOpaque or JWTAlways JWT (ID Token)
User infoNot defined/userinfo endpoint + ID Token claims
ScopesCustom (read, write, etc.)openid, profile, email
DiscoveryNot defined.well-known/openid-configuration
Use caseAPI access (GitHub API, Google Drive)Login with Google/GitHub/etc.

OAuth 2.0 Grant Types

Grant TypeUse CaseClient Type
Authorization CodeWeb apps with a backendConfidential
Authorization Code + PKCESPAs, mobile appsPublic
Client CredentialsMachine-to-machine (no user)Confidential
Device CodeTVs, IoT, CLI toolsPublic
Refresh TokenRenewing expired access tokensBoth

Authorization Code Flow (with PKCE)

This is the recommended flow for all modern applications. PKCE (Proof Key for Code Exchange) protects against authorization code interception attacks and is required for public clients.

┌──────────┐                              ┌──────────────────┐
│  Browser  │                              │  Auth Server     │
│  (Client) │                              │  (e.g., Google)  │
└─────┬─────┘                              └────────┬─────────┘
      │                                             │
      │  1. Generate code_verifier + code_challenge  │
      │  2. Redirect to /authorize                   │
      │  ─────────────────────────────────────────►  │
      │     ?response_type=code                      │
      │     &client_id=xxx                           │
      │     &redirect_uri=xxx                        │
      │     &scope=openid profile email              │
      │     &code_challenge=xxx                      │
      │     &code_challenge_method=S256              │
      │     &state=random_csrf_token                 │
      │                                              │
      │  3. User logs in + consents                  │
      │                                              │
      │  4. Redirect back with code                  │
      │  ◄─────────────────────────────────────────  │
      │     ?code=AUTH_CODE&state=random_csrf_token  │
      │                                              │
      │  5. Exchange code for tokens (POST /token)   │
      │  ─────────────────────────────────────────►  │
      │     grant_type=authorization_code            │
      │     &code=AUTH_CODE                          │
      │     &code_verifier=xxx                       │
      │                                              │
      │  6. Receive tokens                           │
      │  ◄─────────────────────────────────────────  │
      │     { access_token, id_token, refresh_token }│
      └──────────────────────────────────────────────┘

Implementation

// lib/oauth.ts — OAuth 2.0 + PKCE utilities
import crypto from 'node:crypto';

// Step 1: Generate PKCE code verifier and challenge
export function generatePKCE() {
  const codeVerifier = crypto.randomBytes(32)
    .toString('base64url');

  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');

  return { codeVerifier, codeChallenge };
}

// Step 2: Build the authorization URL
export function buildAuthUrl(config: {
  authEndpoint: string;
  clientId: string;
  redirectUri: string;
  scopes: string[];
  codeChallenge: string;
  state: string;
}) {
  const url = new URL(config.authEndpoint);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', config.clientId);
  url.searchParams.set('redirect_uri', config.redirectUri);
  url.searchParams.set('scope', config.scopes.join(' '));
  url.searchParams.set('code_challenge', config.codeChallenge);
  url.searchParams.set('code_challenge_method', 'S256');
  url.searchParams.set('state', config.state);
  return url.toString();
}

// Step 3: Exchange authorization code for tokens
export async function exchangeCode(config: {
  tokenEndpoint: string;
  clientId: string;
  clientSecret?: string;
  code: string;
  redirectUri: string;
  codeVerifier: string;
}) {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: config.clientId,
    code: config.code,
    redirect_uri: config.redirectUri,
    code_verifier: config.codeVerifier,
  });

  if (config.clientSecret) {
    body.set('client_secret', config.clientSecret);
  }

  const response = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Token exchange failed: ${error.error_description}`);
  }

  return response.json() as Promise<{
    access_token: string;
    id_token?: string;
    refresh_token?: string;
    token_type: string;
    expires_in: number;
  }>;
}

Express.js Route Handlers

// routes/auth.ts
import { Router } from 'express';
import { generatePKCE, buildAuthUrl, exchangeCode } from '../lib/oauth';

const router = Router();

// Google OIDC configuration
const GOOGLE_CONFIG = {
  authEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
  tokenEndpoint: 'https://oauth2.googleapis.com/token',
  userinfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  redirectUri: 'http://localhost:3000/auth/callback',
  scopes: ['openid', 'profile', 'email'],
};

// Step 1: Start login — redirect to Google
router.get('/login', (req, res) => {
  const { codeVerifier, codeChallenge } = generatePKCE();
  const state = crypto.randomBytes(16).toString('hex');

  // Store in session for verification
  req.session.codeVerifier = codeVerifier;
  req.session.oauthState = state;

  const authUrl = buildAuthUrl({
    ...GOOGLE_CONFIG,
    codeChallenge,
    state,
  });

  res.redirect(authUrl);
});

// Step 2: Handle callback — exchange code for tokens
router.get('/callback', async (req, res) => {
  const { code, state, error } = req.query;

  // Check for errors
  if (error) {
    return res.redirect(`/login?error=${error}`);
  }

  // Verify state to prevent CSRF
  if (state !== req.session.oauthState) {
    return res.status(403).json({ error: 'Invalid state parameter' });
  }

  try {
    const tokens = await exchangeCode({
      tokenEndpoint: GOOGLE_CONFIG.tokenEndpoint,
      clientId: GOOGLE_CONFIG.clientId,
      clientSecret: GOOGLE_CONFIG.clientSecret,
      code: code as string,
      redirectUri: GOOGLE_CONFIG.redirectUri,
      codeVerifier: req.session.codeVerifier!,
    });

    // Decode the ID token (for OIDC)
    const idToken = decodeJWT(tokens.id_token!);

    // Create session
    req.session.user = {
      sub: idToken.sub,
      email: idToken.email,
      name: idToken.name,
      picture: idToken.picture,
    };
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;

    // Clean up PKCE/state from session
    delete req.session.codeVerifier;
    delete req.session.oauthState;

    res.redirect('/dashboard');
  } catch (err) {
    console.error('OAuth callback error:', err);
    res.redirect('/login?error=token_exchange_failed');
  }
});

// Logout
router.post('/logout', (req, res) => {
  req.session.destroy(() => {
    res.redirect('/');
  });
});

export default router;

ID Token (JWT) Validation

// lib/jwt.ts — ID Token validation
import crypto from 'node:crypto';

interface IDTokenClaims {
  iss: string;    // Issuer
  sub: string;    // Subject (user ID)
  aud: string;    // Audience (your client ID)
  exp: number;    // Expiration time
  iat: number;    // Issued at
  nonce?: string; // Nonce (if sent in auth request)
  email?: string;
  name?: string;
  picture?: string;
}

// Decode JWT without verification (for reading claims)
export function decodeJWT(token: string): IDTokenClaims {
  const [, payload] = token.split('.');
  return JSON.parse(
    Buffer.from(payload, 'base64url').toString('utf-8')
  );
}

// Full ID Token validation
export async function validateIDToken(
  idToken: string,
  config: {
    issuer: string;
    clientId: string;
    jwksUri: string;
  }
): Promise<IDTokenClaims> {
  const [headerB64, payloadB64, signatureB64] = idToken.split('.');

  // 1. Decode header to get key ID
  const header = JSON.parse(
    Buffer.from(headerB64, 'base64url').toString('utf-8')
  );

  // 2. Fetch JWKS and find the signing key
  const jwksResponse = await fetch(config.jwksUri);
  const jwks = await jwksResponse.json();
  const signingKey = jwks.keys.find(
    (key: any) => key.kid === header.kid && key.use === 'sig'
  );

  if (!signingKey) throw new Error('Signing key not found');

  // 3. Verify signature
  const publicKey = crypto.createPublicKey({ key: signingKey, format: 'jwk' });
  const data = Buffer.from(`${headerB64}.${payloadB64}`);
  const signature = Buffer.from(signatureB64, 'base64url');
  const isValid = crypto.verify(
    header.alg === 'RS256' ? 'sha256' : 'sha384',
    data,
    publicKey,
    signature
  );

  if (!isValid) throw new Error('Invalid signature');

  // 4. Validate claims
  const claims = decodeJWT(idToken);
  const now = Math.floor(Date.now() / 1000);

  if (claims.iss !== config.issuer) throw new Error('Invalid issuer');
  if (claims.aud !== config.clientId) throw new Error('Invalid audience');
  if (claims.exp < now) throw new Error('Token expired');

  return claims;
}

Client Credentials Flow (Machine-to-Machine)

// For server-to-server communication (no user involved)
export async function getM2MToken(config: {
  tokenEndpoint: string;
  clientId: string;
  clientSecret: string;
  scopes: string[];
}): Promise<string> {
  const response = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Authorization: `Basic ${Buffer.from(
        `${config.clientId}:${config.clientSecret}`
      ).toString('base64')}`,
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      scope: config.scopes.join(' '),
    }),
  });

  const data = await response.json();
  return data.access_token;
}

// Usage — microservice calling another microservice
const token = await getM2MToken({
  tokenEndpoint: 'https://auth.example.com/oauth/token',
  clientId: process.env.SERVICE_CLIENT_ID!,
  clientSecret: process.env.SERVICE_CLIENT_SECRET!,
  scopes: ['orders:read', 'inventory:write'],
});

const orders = await fetch('https://api.example.com/orders', {
  headers: { Authorization: `Bearer ${token}` },
});

Refresh Token Rotation

// lib/token-refresh.ts
interface TokenStore {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
}

let tokenStore: TokenStore | null = null;

export async function getValidAccessToken(): Promise<string> {
  if (!tokenStore) throw new Error('Not authenticated');

  // Return current token if not expired (with 60s buffer)
  if (Date.now() < (tokenStore.expiresAt - 60) * 1000) {
    return tokenStore.accessToken;
  }

  // Refresh the token
  const response = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: tokenStore.refreshToken,
      client_id: process.env.CLIENT_ID!,
    }),
  });

  if (!response.ok) {
    tokenStore = null;  // Clear invalid tokens
    throw new Error('Refresh token expired — user must re-authenticate');
  }

  const data = await response.json();

  // Update stored tokens (refresh token rotation)
  tokenStore = {
    accessToken: data.access_token,
    refreshToken: data.refresh_token || tokenStore.refreshToken,
    expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
  };

  return tokenStore.accessToken;
}

Security Best Practices

  • Always use PKCE — even for confidential clients, PKCE adds defense-in-depth
  • Validate the state parameter — prevents CSRF attacks on the callback endpoint
  • Validate ID tokens fully — check signature, issuer, audience, and expiration
  • Use short-lived access tokens — 5-15 minutes; rely on refresh tokens for longevity
  • Rotate refresh tokens — issue a new refresh token with each use; invalidate the old one
  • Store tokens securely — httpOnly cookies on the server; never in localStorage for SPAs
  • Use exact redirect URIs — do not use wildcards; register every redirect URI
  • Implement token revocation — call the revocation endpoint on logout

OIDC Discovery

// Automatically discover OAuth/OIDC endpoints from the provider
export async function discoverOIDC(issuer: string) {
  const response = await fetch(
    `${issuer}/.well-known/openid-configuration`
  );
  return response.json() as Promise<{
    authorization_endpoint: string;
    token_endpoint: string;
    userinfo_endpoint: string;
    jwks_uri: string;
    scopes_supported: string[];
    response_types_supported: string[];
    grant_types_supported: string[];
    end_session_endpoint?: string;
    revocation_endpoint?: string;
  }>;
}

// Usage
const google = await discoverOIDC('https://accounts.google.com');
// google.authorization_endpoint → "https://accounts.google.com/o/oauth2/v2/auth"
// google.token_endpoint → "https://oauth2.googleapis.com/token"
// google.jwks_uri → "https://www.googleapis.com/oauth2/v3/certs"

Decode and inspect your JWT tokens with our JWT Decoder tool. For understanding the Base64 encoding used in tokens, try our Base64 Encoder/Decoder. For a broader look at API authentication methods, read our API Authentication Guide.

𝕏 Twitterin LinkedIn
Was dit nuttig?

Blijf op de hoogte

Ontvang wekelijkse dev-tips en nieuwe tools.

Geen spam. Altijd opzegbaar.

Try These Related Tools

JWTJWT Decoder🔑JWT DebuggerB→Base64 Encoder

Related Articles

OAuth 2.0: Complete implementatiegids (2026)

Implementeer OAuth 2.0 vanaf nul.

JWT-authenticatie: Complete implementatiegids

Implementeer JWT-authenticatie vanaf nul. Tokenstructuur, access- en refresh-tokens, Node.js-implementatie, client-side beheer, beveiligings-best-practices en Next.js-middleware.

REST API Design Best Practices: De Complete Gids

Beheers REST API-ontwerp met best practices voor URIs, HTTP-methoden, statuscodes en versionering.