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.