Back to Blog
OAuthOpenID ConnectAuthenticationSecurity

OAuth 2.0 and OpenID Connect: A Developer's Guide

Implement secure authentication and authorization. Understand OAuth flows, tokens, and OpenID Connect for modern applications.

B
Bootspring Team
Engineering
October 20, 2024
5 min read

OAuth 2.0 handles authorization (what you can access). OpenID Connect (OIDC) adds authentication (who you are). Together, they power secure authentication for modern applications.

Understanding the Difference#

OAuth 2.0 = Authorization "Can this app access my photos?" Result: Access token OpenID Connect = Authentication "Who is this user?" Result: ID token + Access token OIDC is built on top of OAuth 2.0

OAuth 2.0 Flows#

Best for: Server-side applications ┌──────┐ ┌────────────────┐ ┌──────────────┐ │ User │────▶│ Authorization │────▶│ Your Server │ └──────┘ │ Server │ └──────────────┘ └────────────────┘ │ ▼ Authorization Code │ ▼ Exchange for Tokens
1// Step 1: Redirect user to authorization server 2const authUrl = new URL('https://auth.example.com/authorize'); 3authUrl.searchParams.set('response_type', 'code'); 4authUrl.searchParams.set('client_id', CLIENT_ID); 5authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback'); 6authUrl.searchParams.set('scope', 'openid profile email'); 7authUrl.searchParams.set('state', generateRandomState()); 8 9res.redirect(authUrl.toString()); 10 11// Step 2: Handle callback with authorization code 12app.get('/callback', async (req, res) => { 13 const { code, state } = req.query; 14 15 // Verify state to prevent CSRF 16 if (state !== req.session.state) { 17 return res.status(400).send('Invalid state'); 18 } 19 20 // Step 3: Exchange code for tokens 21 const tokenResponse = await fetch('https://auth.example.com/token', { 22 method: 'POST', 23 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 24 body: new URLSearchParams({ 25 grant_type: 'authorization_code', 26 code, 27 redirect_uri: 'https://myapp.com/callback', 28 client_id: CLIENT_ID, 29 client_secret: CLIENT_SECRET, 30 }), 31 }); 32 33 const { access_token, id_token, refresh_token } = await tokenResponse.json(); 34 35 // Store tokens securely 36 req.session.accessToken = access_token; 37 req.session.refreshToken = refresh_token; 38 39 res.redirect('/dashboard'); 40});

Authorization Code Flow with PKCE#

Best for: Single-page apps, mobile apps (no client secret) PKCE = Proof Key for Code Exchange Prevents authorization code interception attacks
1// Generate PKCE values 2function generatePKCE() { 3 const verifier = crypto.randomBytes(32).toString('base64url'); 4 const challenge = crypto 5 .createHash('sha256') 6 .update(verifier) 7 .digest('base64url'); 8 9 return { verifier, challenge }; 10} 11 12// Step 1: Authorization request with code_challenge 13const { verifier, challenge } = generatePKCE(); 14sessionStorage.setItem('pkce_verifier', verifier); 15 16const authUrl = new URL('https://auth.example.com/authorize'); 17authUrl.searchParams.set('response_type', 'code'); 18authUrl.searchParams.set('client_id', CLIENT_ID); 19authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback'); 20authUrl.searchParams.set('scope', 'openid profile'); 21authUrl.searchParams.set('code_challenge', challenge); 22authUrl.searchParams.set('code_challenge_method', 'S256'); 23 24window.location.href = authUrl.toString(); 25 26// Step 2: Exchange code with code_verifier 27const verifier = sessionStorage.getItem('pkce_verifier'); 28 29const tokenResponse = await fetch('https://auth.example.com/token', { 30 method: 'POST', 31 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 32 body: new URLSearchParams({ 33 grant_type: 'authorization_code', 34 code, 35 redirect_uri: 'https://myapp.com/callback', 36 client_id: CLIENT_ID, 37 code_verifier: verifier, // No client_secret needed 38 }), 39});

Client Credentials Flow#

Best for: Machine-to-machine communication
1// Direct token request (no user involved) 2const tokenResponse = await fetch('https://auth.example.com/token', { 3 method: 'POST', 4 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 5 body: new URLSearchParams({ 6 grant_type: 'client_credentials', 7 client_id: CLIENT_ID, 8 client_secret: CLIENT_SECRET, 9 scope: 'read:data write:data', 10 }), 11}); 12 13const { access_token } = await tokenResponse.json();

Tokens#

Access Token#

1// JWT access token structure 2{ 3 "header": { 4 "alg": "RS256", 5 "typ": "JWT" 6 }, 7 "payload": { 8 "iss": "https://auth.example.com", 9 "sub": "user_123", 10 "aud": "https://api.myapp.com", 11 "exp": 1704067200, 12 "iat": 1704063600, 13 "scope": "read:profile write:profile" 14 } 15} 16 17// Validate access token 18import jwt from 'jsonwebtoken'; 19 20function validateAccessToken(token: string): TokenPayload { 21 return jwt.verify(token, publicKey, { 22 algorithms: ['RS256'], 23 audience: 'https://api.myapp.com', 24 issuer: 'https://auth.example.com', 25 }); 26}

ID Token (OIDC)#

1// ID token contains user identity claims 2{ 3 "payload": { 4 "iss": "https://auth.example.com", 5 "sub": "user_123", 6 "aud": "client_abc", 7 "exp": 1704067200, 8 "iat": 1704063600, 9 "auth_time": 1704063500, 10 "nonce": "random_nonce", 11 12 // Standard claims 13 "name": "John Doe", 14 "email": "john@example.com", 15 "email_verified": true, 16 "picture": "https://example.com/avatar.jpg" 17 } 18}

Refresh Token#

1// Exchange refresh token for new access token 2async function refreshAccessToken(refreshToken: string) { 3 const response = await fetch('https://auth.example.com/token', { 4 method: 'POST', 5 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 6 body: new URLSearchParams({ 7 grant_type: 'refresh_token', 8 refresh_token: refreshToken, 9 client_id: CLIENT_ID, 10 }), 11 }); 12 13 return response.json(); 14} 15 16// Auto-refresh middleware 17async function authMiddleware(req, res, next) { 18 const accessToken = req.session.accessToken; 19 20 if (isTokenExpired(accessToken)) { 21 try { 22 const tokens = await refreshAccessToken(req.session.refreshToken); 23 req.session.accessToken = tokens.access_token; 24 if (tokens.refresh_token) { 25 req.session.refreshToken = tokens.refresh_token; 26 } 27 } catch { 28 return res.redirect('/login'); 29 } 30 } 31 32 next(); 33}

Scopes#

1// Define what access is requested 2const scopes = [ 3 'openid', // Required for OIDC 4 'profile', // Name, picture, etc. 5 'email', // Email address 6 'offline_access', // Get refresh token 7]; 8 9// API scopes 10const apiScopes = [ 11 'read:users', 12 'write:users', 13 'delete:users', 14];

Security Best Practices#

Token Storage#

1// Server-side: Store in encrypted session 2// Client-side: NEVER store tokens in localStorage 3 4// For SPAs, use httpOnly cookies 5res.cookie('access_token', token, { 6 httpOnly: true, 7 secure: true, 8 sameSite: 'strict', 9 maxAge: 3600000, 10}); 11 12// Or use token handler pattern (BFF) 13// SPA calls your backend, backend handles tokens

Validate Tokens#

1// Always validate: 2// 1. Signature 3// 2. Issuer (iss) 4// 3. Audience (aud) 5// 4. Expiration (exp) 6// 5. Not before (nbf) 7 8function validateToken(token: string) { 9 const decoded = jwt.verify(token, publicKey, { 10 algorithms: ['RS256'], 11 audience: expectedAudience, 12 issuer: expectedIssuer, 13 clockTolerance: 30, // 30 seconds leeway 14 }); 15 16 return decoded; 17}

Prevent Common Attacks#

1// State parameter prevents CSRF 2const state = crypto.randomBytes(16).toString('hex'); 3req.session.oauthState = state; 4authUrl.searchParams.set('state', state); 5 6// Nonce prevents replay attacks (OIDC) 7const nonce = crypto.randomBytes(16).toString('hex'); 8req.session.oauthNonce = nonce; 9authUrl.searchParams.set('nonce', nonce); 10 11// Validate both on callback 12if (req.query.state !== req.session.oauthState) { 13 throw new Error('State mismatch'); 14} 15 16const idToken = jwt.decode(tokens.id_token); 17if (idToken.nonce !== req.session.oauthNonce) { 18 throw new Error('Nonce mismatch'); 19}

Logout#

1// Local logout 2app.post('/logout', (req, res) => { 3 req.session.destroy(); 4 res.redirect('/'); 5}); 6 7// OIDC logout (end session at provider) 8app.post('/logout', (req, res) => { 9 const idToken = req.session.idToken; 10 req.session.destroy(); 11 12 const logoutUrl = new URL('https://auth.example.com/logout'); 13 logoutUrl.searchParams.set('id_token_hint', idToken); 14 logoutUrl.searchParams.set('post_logout_redirect_uri', 'https://myapp.com'); 15 16 res.redirect(logoutUrl.toString()); 17});

Conclusion#

OAuth 2.0 and OIDC provide robust authorization and authentication. Use PKCE for public clients, validate all tokens properly, and never store sensitive tokens in browser storage.

Start with a well-tested library (Auth0, Okta, NextAuth.js) rather than implementing from scratch. The security details matter greatly.

Share this article

Help spread the word about Bootspring