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#
Authorization Code Flow (Recommended)#
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 tokensValidate 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.