Back to Blog
OAuthOIDCAuthenticationSecurity

OAuth 2.0 and OpenID Connect: Modern Authentication

Implement OAuth 2.0 and OIDC authentication. Learn flows, token handling, and security best practices for modern applications.

B
Bootspring Team
Engineering
February 26, 2026
7 min read

OAuth 2.0 and OpenID Connect (OIDC) are the foundation of modern authentication. This guide covers implementing these protocols securely in web applications.

Understanding the Protocols#

OAuth 2.0#

OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts.

┌──────────┐ ┌──────────────┐ │ │──(1) Authorization Request──>│ │ │ │ │ Resource │ │ │<─(2) Authorization Grant────│ Owner │ │ │ │ │ │ Client │ └──────────────┘ │ │ ┌──────────────┐ │ │──(3) Authorization Grant──> │ │ │ │ │ Authorization│ │ │<─(4) Access Token───────── │ Server │ │ │ │ │ │ │ └──────────────┘ │ │ ┌──────────────┐ │ │──(5) Access Token──────────>│ │ │ │ │ Resource │ │ │<─(6) Protected Resource────│ Server │ │ │ │ │ └──────────┘ └──────────────┘

OpenID Connect#

OIDC adds an identity layer on top of OAuth 2.0:

  • ID Token: JWT containing user identity claims
  • UserInfo Endpoint: Returns user profile information
  • Standard Scopes: openid, profile, email, address, phone

Authorization Code Flow with PKCE#

The recommended flow for web applications:

1// Step 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 2: Redirect to authorization endpoint 13function initiateLogin() { 14 const { verifier, challenge } = generatePKCE(); 15 const state = crypto.randomBytes(16).toString('hex'); 16 17 // Store verifier and state in session 18 session.codeVerifier = verifier; 19 session.state = state; 20 21 const params = new URLSearchParams({ 22 client_id: process.env.OAUTH_CLIENT_ID, 23 redirect_uri: 'https://myapp.com/callback', 24 response_type: 'code', 25 scope: 'openid profile email', 26 state, 27 code_challenge: challenge, 28 code_challenge_method: 'S256', 29 }); 30 31 return `https://auth.provider.com/authorize?${params}`; 32} 33 34// Step 3: Handle callback 35async function handleCallback(code: string, state: string) { 36 // Verify state 37 if (state !== session.state) { 38 throw new Error('Invalid state parameter'); 39 } 40 41 // Exchange code for tokens 42 const response = await fetch('https://auth.provider.com/token', { 43 method: 'POST', 44 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 45 body: new URLSearchParams({ 46 grant_type: 'authorization_code', 47 client_id: process.env.OAUTH_CLIENT_ID, 48 client_secret: process.env.OAUTH_CLIENT_SECRET, 49 code, 50 redirect_uri: 'https://myapp.com/callback', 51 code_verifier: session.codeVerifier, 52 }), 53 }); 54 55 const tokens = await response.json(); 56 return tokens; 57}

Token Handling#

Validating ID Tokens#

1import jwt from 'jsonwebtoken'; 2import jwksClient from 'jwks-rsa'; 3 4const client = jwksClient({ 5 jwksUri: 'https://auth.provider.com/.well-known/jwks.json', 6 cache: true, 7 rateLimit: true, 8}); 9 10async function validateIdToken(idToken: string): Promise<TokenPayload> { 11 // Decode header to get key ID 12 const decoded = jwt.decode(idToken, { complete: true }); 13 if (!decoded || typeof decoded === 'string') { 14 throw new Error('Invalid token'); 15 } 16 17 // Get signing key 18 const key = await client.getSigningKey(decoded.header.kid); 19 const publicKey = key.getPublicKey(); 20 21 // Verify token 22 const payload = jwt.verify(idToken, publicKey, { 23 algorithms: ['RS256'], 24 audience: process.env.OAUTH_CLIENT_ID, 25 issuer: 'https://auth.provider.com', 26 }) as TokenPayload; 27 28 // Additional validations 29 const now = Math.floor(Date.now() / 1000); 30 31 if (payload.exp < now) { 32 throw new Error('Token expired'); 33 } 34 35 if (payload.iat > now + 60) { 36 throw new Error('Token issued in the future'); 37 } 38 39 // Verify nonce if present 40 if (session.nonce && payload.nonce !== session.nonce) { 41 throw new Error('Invalid nonce'); 42 } 43 44 return payload; 45}

Refresh Token Flow#

1class TokenManager { 2 private accessToken: string | null = null; 3 private refreshToken: string | null = null; 4 private expiresAt: number = 0; 5 6 async getAccessToken(): Promise<string> { 7 // Check if token needs refresh (with buffer) 8 if (Date.now() >= this.expiresAt - 60000) { 9 await this.refresh(); 10 } 11 12 if (!this.accessToken) { 13 throw new Error('No access token available'); 14 } 15 16 return this.accessToken; 17 } 18 19 private async refresh(): Promise<void> { 20 if (!this.refreshToken) { 21 throw new Error('No refresh token available'); 22 } 23 24 const response = await fetch('https://auth.provider.com/token', { 25 method: 'POST', 26 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 27 body: new URLSearchParams({ 28 grant_type: 'refresh_token', 29 client_id: process.env.OAUTH_CLIENT_ID, 30 client_secret: process.env.OAUTH_CLIENT_SECRET, 31 refresh_token: this.refreshToken, 32 }), 33 }); 34 35 if (!response.ok) { 36 // Refresh token expired or revoked 37 this.clear(); 38 throw new Error('Session expired'); 39 } 40 41 const tokens = await response.json(); 42 this.setTokens(tokens); 43 } 44 45 setTokens(tokens: TokenResponse): void { 46 this.accessToken = tokens.access_token; 47 this.refreshToken = tokens.refresh_token || this.refreshToken; 48 this.expiresAt = Date.now() + tokens.expires_in * 1000; 49 } 50 51 clear(): void { 52 this.accessToken = null; 53 this.refreshToken = null; 54 this.expiresAt = 0; 55 } 56}

Backend for Frontend (BFF) Pattern#

Keep tokens secure on the server:

1// API Route: /api/auth/login 2export async function POST(req: Request) { 3 const { verifier, challenge } = generatePKCE(); 4 const state = crypto.randomUUID(); 5 6 // Store in HTTP-only cookie 7 const session = await encrypt({ verifier, state }); 8 9 cookies().set('auth_session', session, { 10 httpOnly: true, 11 secure: true, 12 sameSite: 'lax', 13 maxAge: 600, // 10 minutes 14 }); 15 16 const authUrl = buildAuthUrl({ state, challenge }); 17 return Response.json({ authUrl }); 18} 19 20// API Route: /api/auth/callback 21export async function GET(req: Request) { 22 const { searchParams } = new URL(req.url); 23 const code = searchParams.get('code'); 24 const state = searchParams.get('state'); 25 26 // Retrieve session 27 const sessionCookie = cookies().get('auth_session'); 28 const session = await decrypt(sessionCookie.value); 29 30 if (state !== session.state) { 31 return Response.redirect('/login?error=invalid_state'); 32 } 33 34 // Exchange code for tokens 35 const tokens = await exchangeCode(code, session.verifier); 36 37 // Validate ID token 38 const idToken = await validateIdToken(tokens.id_token); 39 40 // Create application session 41 const appSession = await encrypt({ 42 userId: idToken.sub, 43 accessToken: tokens.access_token, 44 refreshToken: tokens.refresh_token, 45 expiresAt: Date.now() + tokens.expires_in * 1000, 46 }); 47 48 cookies().set('app_session', appSession, { 49 httpOnly: true, 50 secure: true, 51 sameSite: 'strict', 52 maxAge: 7 * 24 * 60 * 60, // 7 days 53 }); 54 55 return Response.redirect('/dashboard'); 56} 57 58// API Route: /api/proxy/[...path] 59export async function handler(req: Request) { 60 const session = await getSession(); 61 62 if (!session) { 63 return Response.json({ error: 'Unauthorized' }, { status: 401 }); 64 } 65 66 // Refresh if needed 67 if (Date.now() >= session.expiresAt - 60000) { 68 const newTokens = await refreshTokens(session.refreshToken); 69 await updateSession(newTokens); 70 session.accessToken = newTokens.access_token; 71 } 72 73 // Proxy request to resource server 74 const path = req.url.replace('/api/proxy', ''); 75 const response = await fetch(`https://api.service.com${path}`, { 76 method: req.method, 77 headers: { 78 'Authorization': `Bearer ${session.accessToken}`, 79 'Content-Type': 'application/json', 80 }, 81 body: req.method !== 'GET' ? await req.text() : undefined, 82 }); 83 84 return response; 85}

Social Login Integration#

1// Auth.js (NextAuth) configuration 2import NextAuth from 'next-auth'; 3import Google from 'next-auth/providers/google'; 4import GitHub from 'next-auth/providers/github'; 5 6export const { handlers, auth, signIn, signOut } = NextAuth({ 7 providers: [ 8 Google({ 9 clientId: process.env.GOOGLE_CLIENT_ID, 10 clientSecret: process.env.GOOGLE_CLIENT_SECRET, 11 authorization: { 12 params: { 13 prompt: 'consent', 14 access_type: 'offline', 15 response_type: 'code', 16 }, 17 }, 18 }), 19 GitHub({ 20 clientId: process.env.GITHUB_CLIENT_ID, 21 clientSecret: process.env.GITHUB_CLIENT_SECRET, 22 }), 23 ], 24 callbacks: { 25 async jwt({ token, account }) { 26 if (account) { 27 token.accessToken = account.access_token; 28 token.refreshToken = account.refresh_token; 29 token.expiresAt = account.expires_at; 30 } 31 return token; 32 }, 33 async session({ session, token }) { 34 session.accessToken = token.accessToken; 35 return session; 36 }, 37 }, 38});

Security Best Practices#

State and Nonce#

1// Always use state to prevent CSRF 2const state = crypto.randomBytes(32).toString('hex'); 3session.oauthState = state; 4 5// Use nonce for ID token replay protection 6const nonce = crypto.randomBytes(32).toString('hex'); 7session.oauthNonce = nonce; 8 9const authUrl = new URL('https://auth.provider.com/authorize'); 10authUrl.searchParams.set('state', state); 11authUrl.searchParams.set('nonce', nonce);

Token Storage#

1// Never store tokens in localStorage or sessionStorage 2// Bad 3localStorage.setItem('access_token', token); 4 5// Good: HTTP-only cookies 6cookies().set('session', encryptedSession, { 7 httpOnly: true, // Not accessible via JavaScript 8 secure: true, // HTTPS only 9 sameSite: 'strict', // CSRF protection 10 path: '/', 11});

Logout#

1async function logout() { 2 // Clear local session 3 cookies().delete('app_session'); 4 5 // Revoke tokens at provider 6 await fetch('https://auth.provider.com/revoke', { 7 method: 'POST', 8 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 9 body: new URLSearchParams({ 10 token: session.refreshToken, 11 token_type_hint: 'refresh_token', 12 client_id: process.env.OAUTH_CLIENT_ID, 13 client_secret: process.env.OAUTH_CLIENT_SECRET, 14 }), 15 }); 16 17 // Redirect to provider logout (optional) 18 const logoutUrl = new URL('https://auth.provider.com/logout'); 19 logoutUrl.searchParams.set('post_logout_redirect_uri', 'https://myapp.com'); 20 21 return Response.redirect(logoutUrl); 22}

Conclusion#

OAuth 2.0 and OIDC provide secure, standardized authentication. Always use PKCE for public clients, validate tokens properly, store tokens securely in HTTP-only cookies, and implement proper logout. Consider using the BFF pattern for SPAs to keep tokens server-side.

Share this article

Help spread the word about Bootspring