Implementation Flow: Generate Keys → Configure Tokens → Authorization Endpoint → Token Endpoint → Validation → Production Crying
Here's how to build OAuth2 with JWT tokens that actually works in production. Spoiler: it's more complex than the tutorials suggest.

Phase 1: Authorization Server Setup
Generate JWT signing keys. This will break at least 3 times before it works:
## Generate RSA key (this will have wrong permissions, guaranteed)
openssl genrsa -out private_key.pem 4096
## Fix permissions before Docker loses its shit
chmod 600 private_key.pem
chown $(whoami) private_key.pem
## Extract public key
openssl rsa -in private_key.pem -pubout -out public_key.pem
## For production: store these in a proper secret manager
## Don't commit them to Git, you muppet
Real production notes:
- Store keys in AWS Secrets Manager, Azure Key Vault, or similar
- Set up key rotation (you'll forget this until the first security audit)
- Private key needs
600
permissions or everything breaks
- Public key can be
644
but keep it consistent
Step 2: Token Config (AKA The Source of All Future Pain)
JWT Lifecycle: Issue (15min expiry) → Use (until expired) → Refresh (with race conditions) → Repeat (forever)
Here's your token config. These numbers will haunt you:
const tokenConfig = {
accessToken: {
algorithm: 'RS256', // Don't use HS256 in production (trust me)
expiresIn: '15m', // 15 minutes - users will hate you
issuer: 'https://your-auth-server.com', // HTTPS or die
audience: 'your-api-audience' // Must match exactly
},
refreshToken: {
expiresIn: '7d', // 7 days seems reasonable, right? Wrong.
secure: true, // HTTPS only - no HTTP in 2025
httpOnly: true, // Prevents XSS, enables CORS hell
sameSite: 'strict' // Breaks everything, but secure
}
}
Why this will break:
- 15-minute expiry = users logged out mid-task
sameSite: 'strict'
breaks OAuth2 redirects
httpOnly
cookies don't work with fetch()
by default
- Clock skew between servers makes tokens "expire early"
Production reality check:
- Start with 1-hour access tokens, optimize down
- Use
sameSite: 'lax'
for OAuth2 flows
- Add 30-second clock skew tolerance
- Monitor token refresh failure rates
Step 3: Authorization Endpoint (Where Dreams Go to Die)
The authorization endpoint is where users consent to sharing data. It's also where everything breaks:
app.get('/oauth/authorize', async (req, res) => {
const { client_id, redirect_uri, response_type, scope, state, code_challenge } = req.query;
try {
// Validate client - this will fail silently if DB is down
const client = await validateClient(client_id);
if (!client) {
// DON'T redirect to redirect_uri - it might be malicious
return res.status(400).json({ error: 'invalid_client' });
}
// Validate redirect_uri - EXACT match required
if (!client.redirectUris.includes(redirect_uri)) {
return res.status(400).json({ error: 'invalid_redirect_uri' });
}
// Check if user is authenticated
if (!req.session.userId) {
// Store the original request and redirect to login
req.session.oauth_request = req.query;
return res.redirect('/login');
}
// Generate auth code (expires in 10 minutes)
const authCode = generateAuthorizationCode({
client_id,
redirect_uri,
scope: scope.split(' '),
code_challenge,
user_id: req.session.userId,
expires: Date.now() + (10 * 60 * 1000)
});
// Redirect with fragments to prevent log exposure
const url = new URL(redirect_uri);
url.searchParams.set('code', authCode);
if (state) url.searchParams.set('state', state);
res.redirect(url.toString());
} catch (error) {
console.error('OAuth authorize error:', error);
// Never expose internal errors to clients
res.status(500).json({ error: 'server_error' });
}
});
What breaks here:
- Database timeout during
validateClient()
- 30 second hang
- User closes browser during consent flow - orphaned auth codes
- redirect_uri with query params - URL parsing hell
- Missing state parameter - CSRF attacks
Step 4: Implement Token Endpoint
Create the token exchange endpoint:
app.post('/oauth/token', async (req, res) => {
const { grant_type, code, client_id, client_secret, code_verifier } = req.body;
try {
switch (grant_type) {
case 'authorization_code':
const tokenData = await handleAuthorizationCodeGrant({
code, client_id, client_secret, code_verifier
});
break;
case 'refresh_token':
const refreshData = await handleRefreshTokenGrant(req.body);
break;
case 'client_credentials':
const clientData = await handleClientCredentialsGrant({
client_id, client_secret
});
break;
default:
return res.status(400).json({ error: 'unsupported_grant_type' });
}
res.json(tokenData);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Phase 2: JWT Token Generation
Step 5: Create JWT Access Tokens
Implement secure JWT generation with proper claims:
function generateAccessToken(user, client, scope) {
const payload = {
// Standard claims
iss: tokenConfig.accessToken.issuer,
aud: tokenConfig.accessToken.audience,
sub: user.id,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (15 * 60), // 15 minutes
// Custom claims
scope: scope.join(' '),
client_id: client.id,
roles: user.roles,
permissions: user.permissions
};
return jwt.sign(payload, privateKey, {
algorithm: 'RS256',
keyid: 'key-1' // Key rotation support
});
}
Step 6: Implement Refresh Token Rotation

Enhance security with refresh token rotation:
async function handleRefreshTokenGrant({ refresh_token, client_id, client_secret }) {
// Validate refresh token
const storedToken = await getRefreshToken(refresh_token);
if (!storedToken || storedToken.client_id !== client_id) {
throw new Error('invalid_grant');
}
// Validate client credentials
await validateClientCredentials(client_id, client_secret);
// Revoke old refresh token
await revokeRefreshToken(refresh_token);
// Generate new tokens
const user = await getUser(storedToken.user_id);
const newAccessToken = generateAccessToken(user, { id: client_id }, storedToken.scope);
const newRefreshToken = generateRefreshToken();
// Store new refresh token
await storeRefreshToken(newRefreshToken, {
user_id: user.id,
client_id,
scope: storedToken.scope
});
return {
access_token: newAccessToken,
token_type: 'Bearer',
expires_in: 900, // 15 minutes
refresh_token: newRefreshToken,
scope: storedToken.scope.join(' ')
};
}
Phase 3: Client-Side Integration
Step 7: Implement Authorization Code Flow with PKCE

Secure client-side implementation using PKCE:
class OAuth2Client {
constructor(clientId, redirectUri, authServerUrl) {
this.clientId = clientId;
this.redirectUri = redirectUri;
this.authServerUrl = authServerUrl;
}
// Generate PKCE challenge
generatePKCE() {
const codeVerifier = this.generateRandomString(128);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(
crypto.subtle.digestSync('SHA-256', new TextEncoder().encode(codeVerifier))
))).replace(/[+/]/g, c => c == '+' ? '-' : '_').replace(/=/g, '');
return { codeVerifier, codeChallenge };
}
// Initiate authorization flow
async authorize(scope = 'openid profile email') {
const { codeVerifier, codeChallenge } = this.generatePKCE();
const state = this.generateRandomString(32);
// Store for later use
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${this.authServerUrl}/oauth/authorize?${params}`;
}
// Exchange authorization code for tokens
async exchangeCodeForTokens(code, state) {
// Verify state parameter
const storedState = sessionStorage.getItem('oauth_state');
if (state !== storedState) {
throw new Error('Invalid state parameter');
}
const codeVerifier = sessionStorage.getItem('code_verifier');
const response = await fetch(`${this.authServerUrl}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: this.clientId,
redirect_uri: this.redirectUri,
code_verifier: codeVerifier
})
});
const tokens = await response.json();
// Store tokens securely (implement secure token storage)
await this.storeTokensSecurely(tokens);
return tokens;
}
}
Phase 4: Resource Server Protection
Step 8: Implement JWT Validation Middleware
Create middleware to validate JWT tokens on protected resources:
const validateJWT = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'access_denied' });
}
const token = authHeader.substring(7);
try {
// Verify token signature and claims
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: tokenConfig.accessToken.issuer,
audience: tokenConfig.accessToken.audience
});
// Additional validation
if (!decoded.scope) {
return res.status(403).json({ error: 'insufficient_scope' });
}
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'token_expired' });
}
return res.status(401).json({ error: 'invalid_token' });
}
};
This implementation looks clean on paper. In reality, OAuth2 JWT auth will break in ways that make you question your life choices. Even with "perfect" code.
You'll spend more time fixing edge cases than building features. But hey, at least now you'll have auth that scales until it doesn't.
Time to prepare for the inevitable 3AM troubleshooting sessions when everything breaks simultaneously.
The 4 Resources That Actually Work When Everything's On Fire