OAuth 2.0 and OpenID Connect: The Implementation Guide Nobody Wrote
A practical guide to implementing OAuth 2.0 and OpenID Connect in your applications. Covers authorization code flow, PKCE, token management, and common security mistakes with working code examples.
OAuth 2.0 has the worst documentation-to-complexity ratio of any protocol I've encountered. The RFCs are dense, the blog posts are either too simplified or too academic, and half the tutorials show you the "implicit flow" which has been deprecated for years.
Here's what I wish someone had told me when I first implemented OAuth: it's not actually that complicated once you strip away the jargon. You're just exchanging codes for tokens, and tokens for access. That's it.
The Mental Model
Forget the formal definitions for a moment. Here's what happens when a user clicks "Sign in with Google":
- Your app redirects the user to Google's login page
- The user logs in to Google (not your app)
- Google redirects back to your app with a short-lived authorization code
- Your server exchanges that code for an access token (and optionally an ID token)
- You use the access token to call Google APIs on behalf of the user
OAuth 2.0 vs OpenID Connect
OAuth 2.0 is about authorization -- "can this app access my Google Drive files?" It gives you an access token but doesn't tell you who the user is.
OpenID Connect (OIDC) is a thin layer on top of OAuth that adds authentication -- "who is this person?" It adds an ID token (a JWT) that contains the user's identity information.
In practice, you almost always want both. Use OIDC to know who the user is, and OAuth to access their resources.
Authorization Code Flow with PKCE
This is the flow you should use for any application -- web apps, mobile apps, SPAs. The older "implicit flow" is deprecated because it puts tokens in URLs where they can leak.
Step 1: Generate the Authorization URL
const crypto = require('crypto');
function generateAuthUrl(provider) {
// PKCE: Generate a random verifier and its SHA256 hash
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// State parameter prevents CSRF attacks
const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: 'http://localhost:3000/auth/callback',
scope: 'openid email profile',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
// Store verifier and state in session (you need these later)
return {
url: https://accounts.google.com/o/oauth2/v2/auth?${params},
codeVerifier,
state,
};
}
PKCE (Proof Key for Code Exchange, pronounced "pixy") prevents authorization code interception attacks. You generate a random code_verifier, send its hash as code_challenge, and later prove you're the original requester by sending the actual verifier.
Step 2: Handle the Callback
After the user logs in, Google redirects to your callback URL with a code and state parameter:
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state matches what we stored (CSRF protection)
if (state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter');
}
try {
// Exchange authorization code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'http://localhost:3000/auth/callback',
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code_verifier: req.session.codeVerifier,
}),
});
const tokens = await tokenResponse.json();
if (tokens.error) {
throw new Error(tokens.error_description || tokens.error);
}
// tokens contains:
// - access_token: for API calls
// - id_token: JWT with user info (OIDC)
// - refresh_token: for getting new access tokens (if requested)
// - expires_in: seconds until access_token expires
// Decode and verify the ID token
const userInfo = decodeIdToken(tokens.id_token);
// Create or update user in your database
const user = await upsertUser({
providerId: userInfo.sub, // unique ID from Google
email: userInfo.email,
name: userInfo.name,
picture: userInfo.picture,
});
// Set your own session
req.session.userId = user.id;
res.redirect('/dashboard');
} catch (err) {
console.error('OAuth callback error:', err);
res.redirect('/login?error=auth_failed');
}
});
Step 3: Decode the ID Token
The ID token is a JWT. You should verify its signature in production, but here's the decoding:
function decodeIdToken(idToken) {
const parts = idToken.split('.');
if (parts.length !== 3) throw new Error('Invalid JWT');
const payload = JSON.parse(
Buffer.from(parts[1], 'base64url').toString()
);
// In production, verify:
// 1. Signature using Google's public keys (JWKS)
// 2. iss (issuer) matches expected value
// 3. aud (audience) matches your client ID
// 4. exp (expiry) hasn't passed
if (payload.aud !== process.env.GOOGLE_CLIENT_ID) {
throw new Error('Token audience mismatch');
}
if (payload.exp * 1000 < Date.now()) {
throw new Error('Token expired');
}
return payload;
}
For proper JWT verification with JWKS:
const jose = require('jose');
async function verifyIdToken(idToken) {
const JWKS = jose.createRemoteJWKSet(
new URL('https://www.googleapis.com/oauth2/v3/certs')
);
const { payload } = await jose.jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.google.com',
audience: process.env.GOOGLE_CLIENT_ID,
});
return payload;
}
Refresh Tokens
Access tokens expire quickly (usually 1 hour). Refresh tokens let you get new access tokens without making the user log in again:
async function refreshAccessToken(refreshToken) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
}),
});
const data = await response.json();
if (data.error) throw new Error(data.error);
return {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
}
Store refresh tokens encrypted in your database. They're long-lived and powerful -- if someone steals one, they have ongoing access to the user's account.
Supporting Multiple Providers
Most apps want Google, GitHub, and maybe Microsoft login. Abstract the provider-specific details:
const providers = {
google: {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo',
scopes: 'openid email profile',
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
github: {
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: 'read:user user:email',
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
};
app.get('/auth/:provider', (req, res) => {
const provider = providers[req.params.provider];
if (!provider) return res.status(404).send('Unknown provider');
const { url, codeVerifier, state } = generateAuthUrl(provider);
req.session.oauthState = state;
req.session.codeVerifier = codeVerifier;
req.session.provider = req.params.provider;
res.redirect(url);
});
One gotcha with GitHub: it doesn't support OIDC or PKCE (as of early 2026). You get an access token but no ID token. You have to call their /user API separately to get the user's identity.
Common Mistakes
Storing tokens in localStorage. Any XSS vulnerability exposes all your tokens. Use httpOnly cookies for session management and keep tokens server-side. Not validating thestate parameter. Without state validation, an attacker can forge OAuth callbacks and potentially link their account to your user's session.
Using the implicit flow. It puts tokens in URL fragments. Those show up in browser history, server logs, and referrer headers. Authorization code flow with PKCE replaced this years ago.
Not handling token expiration. Your app will randomly break when access tokens expire. Always check expiration before making API calls and refresh proactively.
Trusting the email from the ID token blindly. Not all providers verify email addresses. Google does, but other providers might not. Check the email_verified claim.
The Minimal Working Example
Here's everything tied together in a working Express app:
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET || 'change-this',
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production' }
}));
app.get('/login', (req, res) => {
res.send(
<a href="/auth/google">Sign in with Google</a><br>
<a href="/auth/github">Sign in with GitHub</a>
);
});
app.get('/auth/google', (req, res) => {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
const state = crypto.randomBytes(16).toString('hex');
req.session.codeVerifier = verifier;
req.session.oauthState = state;
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: ${process.env.BASE_URL}/auth/google/callback,
scope: 'openid email profile',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
access_type: 'offline', // Request refresh token
prompt: 'consent',
});
res.redirect(https://accounts.google.com/o/oauth2/v2/auth?${params});
});
// ... callback handler as shown above ...
app.get('/dashboard', (req, res) => {
if (!req.session.userId) return res.redirect('/login');
res.send(Welcome, user ${req.session.userId});
});
app.listen(3000);
OAuth isn't rocket science, but it has a lot of moving parts that need to fit together correctly. Get the flow right, validate everything, keep tokens secure, and you'll be fine.