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
- [ ]
jtistored for revocation checks on sensitive endpoints