Understanding OAuth 2.0
OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts on third-party services. It works by delegating user authentication to the service that hosts the account and authorizing third-party applications to access that account. OAuth 2.0 is not an authentication protocol — it is an authorization delegation protocol. The distinction matters: OAuth tells you what a user has allowed an app to do, not who the user is.
Before OAuth, if you wanted to let a third-party app access your email, you had to give it your actual email password. OAuth eliminates this dangerous pattern by introducing access tokens — limited, revocable credentials that grant specific permissions without exposing the user's password.
OAuth 2.0 Roles
- Resource Owner: The user who authorizes an application to access their account. The resource owner's access is limited to the scope of the authorization granted.
- Client: The application requesting access to the user's account. It must be authorized by the user and validated by the authorization server.
- Authorization Server: Verifies the identity of the resource owner and issues access tokens. Examples include Google, GitHub, and Auth0.
- Resource Server: Hosts the protected resources (APIs). It accepts and validates access tokens issued by the authorization server.
Authorization Code Flow (Recommended)
The Authorization Code flow is the most secure OAuth 2.0 flow for server-side applications. It involves exchanging an authorization code for an access token via a back-channel (server-to-server) request, so the access token is never exposed to the browser. For public clients (SPAs, mobile apps), this flow should be combined with PKCE (Proof Key for Code Exchange).
import crypto from "crypto";
// Step 1: Generate PKCE challenge
function generatePKCE() {
// Code verifier: random 43-128 character string
const codeVerifier = crypto.randomBytes(32).toString("base64url");
// Code challenge: SHA-256 hash of the verifier
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
return { codeVerifier, codeChallenge };
}
// Step 2: Redirect user to authorization server
function getAuthorizationUrl(config: OAuthConfig) {
const { codeVerifier, codeChallenge } = generatePKCE();
const state = crypto.randomBytes(16).toString("hex");
// Store codeVerifier and state in session (server-side)
session.oauthState = state;
session.codeVerifier = codeVerifier;
const params = new URLSearchParams({
response_type: "code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: "openid profile email",
state: state, // CSRF protection
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return `${config.authorizationEndpoint}?${params.toString()}`;
}
// Step 3: Handle the callback and exchange code for tokens
async function handleOAuthCallback(req: Request) {
const { code, state } = req.query;
// Verify state matches (CSRF protection)
if (state !== req.session.oauthState) {
throw new SecurityError("Invalid state parameter - possible CSRF attack");
}
// Exchange authorization code for tokens
const tokenResponse = await fetch(config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code as string,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: req.session.codeVerifier, // PKCE proof
}),
});
if (!tokenResponse.ok) {
throw new Error("Token exchange failed");
}
const tokens = await tokenResponse.json();
// tokens contains: access_token, id_token, refresh_token, expires_in
// Clean up session
delete req.session.oauthState;
delete req.session.codeVerifier;
return tokens;
}
OpenID Connect (OIDC)
OpenID Connect is an identity layer built on top of OAuth 2.0. While OAuth 2.0 only handles authorization (what can the app do?), OIDC adds authentication (who is the user?). OIDC introduces the concept of an ID Token — a JWT that contains claims about the authenticated user such as their name, email, and unique identifier.
When you use "Sign in with Google" or "Sign in with GitHub," you are using OpenID Connect. The key difference from plain OAuth is the openid scope and the ID Token that comes back in the response.
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
// Verify an OpenID Connect ID Token
const client = jwksClient({
jwksUri: "https://accounts.google.com/.well-known/jwks",
cache: true,
rateLimit: true,
});
function getSigningKey(header: jwt.JwtHeader): Promise<string> {
return new Promise((resolve, reject) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) return reject(err);
resolve(key!.getPublicKey());
});
});
}
async function verifyIdToken(idToken: string): Promise<OIDCClaims> {
return new Promise((resolve, reject) => {
jwt.verify(
idToken,
(header, callback) => {
getSigningKey(header).then(
(key) => callback(null, key),
(err) => callback(err)
);
},
{
algorithms: ["RS256"],
audience: process.env.GOOGLE_CLIENT_ID, // Must match your client ID
issuer: "https://accounts.google.com", // Must match expected issuer
clockTolerance: 5, // 5 seconds clock skew tolerance
},
(err, decoded) => {
if (err) return reject(new Error(`ID token verification failed: ${err.message}`));
resolve(decoded as OIDCClaims);
}
);
});
}
interface OIDCClaims {
iss: string; // Issuer
sub: string; // Subject (unique user ID)
aud: string; // Audience (your client ID)
exp: number; // Expiration time
iat: number; // Issued at
email?: string;
email_verified?: boolean;
name?: string;
picture?: string;
}
// Complete OIDC login flow
async function handleOIDCLogin(tokens: TokenResponse) {
// Always verify the ID token
const claims = await verifyIdToken(tokens.id_token);
// Verify email is verified (important!)
if (!claims.email_verified) {
throw new Error("Email not verified by identity provider");
}
// Find or create user in your database
let user = await db.query(
"SELECT * FROM users WHERE oauth_provider = $1 AND oauth_id = $2",
["google", claims.sub]
);
if (!user.rows.length) {
user = await db.query(
"INSERT INTO users (email, name, oauth_provider, oauth_id, avatar_url) VALUES ($1, $2, $3, $4, $5) RETURNING *",
[claims.email, claims.name, "google", claims.sub, claims.picture]
);
}
return user.rows[0];
}
Security Warning: OAuth Pitfalls
- Always validate the state parameter: Without state validation, your application is vulnerable to CSRF attacks that can lead to account hijacking.
- Always use PKCE: Even for server-side apps, PKCE prevents authorization code interception attacks.
- Always verify ID tokens: Verify the signature, issuer, audience, and expiration. Never trust an unverified token.
- Never use the Implicit flow: The Implicit flow exposes tokens in the URL and has been deprecated. Use Authorization Code with PKCE instead.
- Store tokens securely: Access tokens should be in memory or secure HttpOnly cookies. Never store them in localStorage.
Token Refresh Pattern
Access tokens should be short-lived (typically 15-60 minutes) to limit the damage if they are compromised. Refresh tokens are longer-lived and can be used to obtain new access tokens without requiring the user to re-authenticate. Refresh tokens must be stored securely and should be rotated on each use.
// Token refresh with rotation
async function refreshAccessToken(refreshToken: string) {
const response = await fetch(config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) {
// Refresh token may be expired or revoked
throw new Error("Token refresh failed - re-authentication required");
}
const tokens = await response.json();
// IMPORTANT: The response may include a new refresh token
// Always use the latest refresh token (rotation)
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
// Automatic token refresh middleware
async function authenticatedFetch(url: string, options: RequestInit = {}) {
let accessToken = getStoredAccessToken();
// Check if token is about to expire (5 min buffer)
if (isTokenExpiringSoon(accessToken, 300)) {
const refreshToken = getStoredRefreshToken();
const newTokens = await refreshAccessToken(refreshToken);
accessToken = newTokens.accessToken;
storeTokens(newTokens);
}
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
}
OAuth 2.0 Flow Comparison
| Flow | Use Case | Security Level |
|---|---|---|
| Authorization Code + PKCE | Web apps, SPAs, mobile | High (recommended) |
| Client Credentials | Service-to-service | High (no user context) |
| Device Code | TVs, CLI tools, IoT | Medium |
| Implicit (deprecated) | Legacy SPAs | Low (avoid) |