Homechevron_rightBlogchevron_rightSecurity
Securityschedule10 min read28 March 2025

OAuth2 & OpenID Connect: A Practical Implementation Guide

Not just theory — how to implement a real OAuth2 Authorization Server with PKCE, refresh token rotation, and OpenID Connect discovery, from someone who built one.

OAuth2OpenID ConnectJWTSecurityNode.js
smart_toy

AI-Assisted Content. This article was generated with AI and reviewed for accuracy based on real engineering experience. Code examples are tested and production-relevant.

Introduction

Most engineers use OAuth2 as a consumer — plugging in Google or GitHub login. Fewer have had to build an authorization server. This is the guide I wish I had when building the Authentication Service at SunCulture.


Core Concepts (Fast)

| Term | What it actually is | |------|-------------------| | Authorization Server | Issues tokens after verifying identity | | Resource Server | Your API — validates tokens | | Client | The app requesting access | | PKCE | Proof Key for Code Exchange — stops auth code interception | | ID Token | JWT proving who the user is (OpenID Connect) | | Access Token | Credential for calling APIs | | Refresh Token | Long-lived token to get new access tokens |


The Authorization Code Flow + PKCE

1. Client generates code_verifier (random 64-byte string)
2. Client hashes it → code_challenge = BASE64URL(SHA256(code_verifier))
3. Client redirects user to /authorize?code_challenge=...&code_challenge_method=S256
4. User authenticates → Auth Server returns authorization_code
5. Client exchanges code + code_verifier for tokens at /token
6. Auth Server verifies SHA256(code_verifier) === stored code_challenge ✓

This prevents an attacker who intercepts the authorization code from exchanging it — they don't have the code_verifier.


Token Structure

// Access token payload
{
  "sub": "usr_abc123",          // User ID
  "iss": "https://auth.myapp.com",
  "aud": ["api.myapp.com"],
  "iat": 1714000000,
  "exp": 1714003600,            // 1 hour
  "scope": "read:patients write:appointments",
  "jti": "tok_xyz789"           // Token ID for revocation
}

// ID token (OpenID Connect) — who the user IS
{
  "sub": "usr_abc123",
  "email": "user@example.com",
  "name": "Dennis Atonya",
  "iss": "https://auth.myapp.com",
  "aud": "client_id_here",
  "exp": 1714003600,
  "nonce": "random_nonce"       // Replay attack prevention
}

Refresh Token Rotation

Never issue long-lived refresh tokens without rotation. When a refresh token is used, issue a new one and immediately invalidate the old:

async function rotateRefreshToken(oldToken: string): Promise<TokenPair> {
  const stored = await db.refreshTokens.findOne({ token: hash(oldToken) });

  if (!stored || stored.usedAt) {
    // Token reuse detected — revoke entire family
    await db.refreshTokens.revokeFamily(stored?.familyId);
    throw new TokenReuseError();
  }

  await db.refreshTokens.markUsed(stored.id);

  const newRefresh = generateSecureToken();
  await db.refreshTokens.create({
    token: hash(newRefresh),
    userId: stored.userId,
    familyId: stored.familyId,
    expiresAt: addDays(new Date(), 30),
  });

  return { accessToken: issueAccessToken(stored.userId), refreshToken: newRefresh };
}

OpenID Connect Discovery

Publish a /.well-known/openid-configuration endpoint so clients can auto-configure:

{
  "issuer": "https://auth.myapp.com",
  "authorization_endpoint": "https://auth.myapp.com/authorize",
  "token_endpoint": "https://auth.myapp.com/token",
  "userinfo_endpoint": "https://auth.myapp.com/userinfo",
  "jwks_uri": "https://auth.myapp.com/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"]
}

Checklist Before Going Live

  • [ ] Access tokens expire in ≤ 1 hour
  • [ ] Refresh tokens rotate on every use
  • [ ] Token reuse triggers full family revocation
  • [ ] PKCE enforced for all public clients
  • [ ] JWKS endpoint published for token verification
  • [ ] Tokens signed with RS256 (asymmetric) not HS256
  • [ ] jti stored for revocation checks on sensitive endpoints