OAuth 2.0 and OpenID Connect form the backbone of modern web authentication and authorization. Despite their ubiquity, these protocols are frequently misunderstood and misconfigured, leading to preventable security vulnerabilities. This guide covers the core concepts, implementation patterns, and security best practices you need to integrate authentication securely in your applications.
OAuth 2.0 Fundamentals
OAuth 2.0 is an authorization framework, not an authentication protocol. This distinction is critical: OAuth defines how a client application can obtain delegated access to protected resources, but it does not specify how to verify the user’s identity. That is where OpenID Connect comes in.
The protocol defines four roles:
| Role | Description | Example |
|---|---|---|
| Resource Owner | Entity that can grant access | End user |
| Client | Application requesting access | Web app, mobile app |
| Authorization Server | Issues tokens after successful authentication | Auth0, Keycloak |
| Resource Server | Hosts protected resources | API server |
The abstract flow is consistent across all grant types: the client requests authorization, the resource owner grants consent, the authorization server issues tokens, and the client uses those tokens to access resources.
Authorization Code Flow with PKCE
The authorization code flow with PKCE is the recommended grant for most applications, including native mobile apps and single-page applications. PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks by binding the authorization request to the token request through a cryptographic challenge.
// Step 1: Generate code verifier and challenge
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
// Step 2: Redirect user to authorization server
const { verifier, challenge } = generatePKCE();
const authUrl = new URL("https://authorization-server.com/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", CALLBACK_URL);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", crypto.randomUUID());
// Step 3: Exchange authorization code for tokens
async function exchangeCode(code, verifier) {
const response = await fetch("https://authorization-server.com/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: CALLBACK_URL,
client_id: CLIENT_ID,
code_verifier: verifier,
}),
});
return response.json(); // { access_token, refresh_token, id_token, expires_in }
}
The state parameter provides CSRF protection. PKCE is now recommended even for server-side web applications, as it provides defense in depth against authorization code interception.
Grant Types Comparison
| Grant Type | Use Case | Security Profile |
|---|---|---|
| Authorization Code + PKCE | Web, mobile, SPA | Strongest — recommended for all new apps |
| Client Credentials | Server-to-server, service accounts | No user context, secure backend channel |
| Device Authorization | CLI, IoT, smart TVs | Requires user to visit a URL on another device |
| Authorization Code (no PKCE) | Legacy server-side apps | Vulnerable to code interception |
| Implicit (deprecated) | Legacy SPAs | Removed from spec — no longer secure |
OpenID Connect Layer
OpenID Connect (OIDC) adds an identity layer on top of OAuth 2.0. The key addition is the id_token, a JWT that contains verified claims about the authenticated user.
// Verifying an ID token in Node.js
const jwksClient = require("jwks-rsa");
const jwt = require("jsonwebtoken");
const client = jwksClient({
jwksUri: "https://authorization-server.com/.well-known/jwks.json",
});
async function verifyIdToken(idToken) {
const decoded = jwt.decode(idToken, { complete: true });
const key = await client.getSigningKey(decoded.header.kid);
const signingKey = key.getPublicKey();
return jwt.verify(idToken, signingKey, {
issuer: "https://authorization-server.com",
audience: CLIENT_ID,
algorithms: ["RS256"],
});
}
Standard ID token claims include sub (subject identifier), iss (issuer), aud (audience), exp (expiration), iat (issued at), and nonce (replay prevention). Always validate the signature, issuer, audience, and expiry before trusting an ID token.
ID Tokens vs. Access Tokens
A common source of confusion is the difference between ID tokens and access tokens. ID tokens are for authentication — they tell the client who the user is. Access tokens are for authorization — they tell the resource server what the client is allowed to do.
ID tokens must never be used for API authorization. Access tokens may be opaque (a random string that the resource server verifies via introspection) or JWTs (self-contained with embedded claims). Use the userinfo endpoint to retrieve additional claims about the user from the authorization server.
Refresh Token Rotation
Refresh token rotation is a security mechanism where each refresh operation returns a new refresh token and invalidates the previous one. This limits the window of opportunity if a refresh token is stolen.
async function refreshAccessToken(refreshToken) {
const response = await fetch("https://authorization-server.com/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
const tokens = await response.json();
// Old refresh token is invalidated; store the new one
await secureStore.save("refresh_token", tokens.refresh_token);
return tokens.access_token;
}
Major providers including Auth0, Okta, and Keycloak now enable refresh token rotation by default. Coupled with reuse detection (revoking all tokens if a rotated token is reused), this provides strong protection against token theft.
Security Best Practices
Token storage strategy is one of the most important security decisions. For browser-based applications, store access tokens in memory and use httpOnly cookies for refresh tokens. Never store tokens in localStorage, as it is accessible to any JavaScript on the same origin.
| Storage Method | Access Token | Refresh Token |
|---|---|---|
| In-memory variable | Best for SPAs (not persisted) | Not suitable |
| httpOnly cookie | Not recommended | Best — protected from XSS |
| localStorage | Vulnerable to XSS | Vulnerable to XSS |
| Secure memory (WebAuthn) | Emerging approach | Emerging approach |
Always validate redirect URIs server-side, use short-lived access tokens (15–60 minutes), and minimize requested scopes to the minimum required for functionality.
Conclusion
OAuth 2.0 and OpenID Connect are powerful but nuanced protocols. Use the authorization code flow with PKCE for all new applications, validate ID tokens thoroughly on the client side, implement refresh token rotation, and never store tokens in localStorage. By following these patterns and understanding the security properties of each grant type, you can build authentication systems that are both user-friendly and resistant to common attacks.
