I spent an embarrassing amount of time early in my career confusing OAuth with "login" and thinking JWTs were encrypted. If that hits close to home, you're in the right place.
Authentication sounds simple: check if the password is right, let them in. But then you start dealing with tokens, sessions, cookies, refresh flows, redirect URIs, and suddenly you're reading RFC 6749 at 2 AM wondering where it all went wrong.
This article covers every auth method I've had to work with, from the dead-simple ones to OAuth2 and OIDC. Diagrams, real code, trade-offs. No hand-waving.
1. Authentication vs Authorisation vs Auth Frameworks
These three get mixed up constantly. I've seen "auth" used to mean all three in the same meeting. Let's fix that.
Authentication (AuthN)
Authentication answers: "Who are you?"
It's the process of verifying a user's identity. When you type your email and password into a login form, or scan your fingerprint, or paste an API key into a header, you're authenticating.
Authorisation (AuthZ)
Authorisation answers: "What are you allowed to do?"
It happens after authentication. You've proven who you are; now the system checks whether you have permission to do what you're trying to do. Can this user delete posts? Can this API key access the billing endpoint? That's authorisation.
Auth Frameworks
Auth frameworks are libraries and platforms that handle both authentication and authorisation for you, so you don't have to build everything from scratch. Examples include:
- NextAuth.js / Auth.js: authentication for Next.js apps
- Passport.js: middleware-based auth for Express/Node
- Spring Security: comprehensive auth for Java applications
- Keycloak: open-source identity and access management
- Auth0 / Clerk / Supabase Auth: managed auth-as-a-service platforms
How They Relate
flowchart LR
A[User] -->|Credentials| B[Authentication]
B -->|Identity confirmed| C[Authorisation]
C -->|Permissions checked| D[Access Granted / Denied]
style B fill:#7c3aed,color:#fff
style C fill:#0891b2,color:#fff
Think of it like a nightclub. Authentication is the bouncer checking your ID at the door. Authorisation is the VIP list that decides whether you get into the lounge or stay in the general area. The auth framework is the security company that trained the bouncer and printed the VIP list.
2. Types of Authentication
Before we go deep on each one, here's the quick overview:
| Method | Best For | Statefulness |
|---|---|---|
| Basic Auth | Internal tools, simple scripts | Stateless |
| Digest Auth | Legacy systems needing Basic improvement | Stateless |
| Session-based | Traditional web apps | Stateful (server) |
| API Key | Service-to-service, public APIs | Stateless |
| JWT / Bearer Token | SPAs, mobile apps, microservices | Stateless |
| OAuth2 | Third-party access delegation | Depends on grant |
| OIDC | Federated login ("Sign in with Google") | Depends on flow |
| SSO | Enterprise / multi-app environments | Stateful (IdP) |
3. Basic Authentication & Digest Authentication
Basic Authentication
Basic Auth does exactly what it sounds like. You send a username and password with every request, Base64-encoded in a header. That's it. No tokens, no sessions, no redirects. RFC 7617 if you want the formal spec.
How It Works
sequenceDiagram
participant C as Client
participant S as Server
C->>S: GET /api/resource
S-->>C: 401 Unauthorized<br/>WWW-Authenticate: Basic realm="API"
C->>S: GET /api/resource<br/>Authorization: Basic dXNlcjpwYXNz
S-->>C: 200 OK (resource data)
- The client makes a request without credentials
- The server responds with
401 Unauthorizedand aWWW-Authenticate: Basicheader - The client re-sends the request with an
Authorizationheader containingBasic <base64(username:password)> - The server decodes, validates, and responds
Code Example
Client (Node.js):
const username = "admin";
const password = "s3cureP@ss";
const encoded = Buffer.from(`${username}:${password}`).toString("base64");
const response = await fetch("https://api.example.com/data", {
headers: {
Authorization: `Basic ${encoded}`,
},
});
Server (Express):
import { timingSafeEqual } from "crypto";
function basicAuth(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith("Basic ")) {
return res
.status(401)
.set("WWW-Authenticate", 'Basic realm="API"')
.json({ error: "Authentication required" });
}
const decoded = Buffer.from(header.slice(6), "base64").toString();
const [username, password] = decoded.split(":");
// Use timing-safe comparison to prevent timing attacks
const validUser = Buffer.from("admin");
const validPass = Buffer.from("s3cureP@ss");
const userMatch = timingSafeEqual(Buffer.from(username), validUser);
const passMatch = timingSafeEqual(Buffer.from(password), validPass);
if (userMatch && passMatch) {
req.user = { username };
return next();
}
res.status(401).json({ error: "Invalid credentials" });
}
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Dead simple to implement | Credentials sent with every request |
| Supported by every HTTP client | Base64 is encoding, not encryption. Trivially decoded |
| No session state needed | No built-in logout mechanism |
| Great for quick internal tools | Vulnerable to credential theft without HTTPS |
Production rule: Never use Basic Auth without TLS/HTTPS. Base64 is not security. It's a postcard with your password written on it.
Digest Authentication
Digest Auth (RFC 7616) was created to fix Basic Auth's biggest flaw: sending passwords in the clear. Instead of transmitting the password, it sends a hash (digest) of the password combined with a server-provided nonce.
How It Works
sequenceDiagram
participant C as Client
participant S as Server
C->>S: GET /api/resource
S-->>C: 401 Unauthorized<br/>WWW-Authenticate: Digest realm="API",<br/>nonce="abc123", qop="auth"
Note over C: Computes: MD5(username:realm:password)<br/>then MD5(method:URI)<br/>then MD5(HA1:nonce:HA2)
C->>S: GET /api/resource<br/>Authorization: Digest username="admin",<br/>nonce="abc123", response="8f3d..."
S-->>C: 200 OK
The password never travels over the wire, only a hash that incorporates a one-time nonce. This prevents replay attacks and credential interception.
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Password never sent in plaintext | Complex to implement correctly |
| Nonce prevents replay attacks | Server must store passwords in a reversible format |
| Better than Basic without TLS | Relies on MD5 which is cryptographically broken |
| Widely supported in HTTP clients | Largely replaced by Basic + TLS in modern systems |
In practice, Digest Auth is mostly a relic. Modern systems use Basic Auth over HTTPS or move to token-based approaches. You'll encounter Digest in legacy SIP/VoIP systems and older network equipment.
4. Session-Based Authentication
If you've built any traditional web app, you've used this. It's the approach that powered basically the entire web before SPAs took over. The server remembers who you are. Your session lives in Redis, a database, or (please don't) in-memory on the server.
How It Works
sequenceDiagram
participant B as Browser
participant S as Server
participant DB as Session Store<br/>(Redis/DB)
B->>S: POST /login<br/>{email, password}
S->>DB: Verify credentials
DB-->>S: User found ✓
S->>DB: Create session<br/>{userId, role, expiry}
DB-->>S: sessionId: "abc123"
S-->>B: 200 OK<br/>Set-Cookie: sessionId=abc123; HttpOnly; Secure
Note over B: Cookie stored automatically<br/>by the browser
B->>S: GET /dashboard<br/>Cookie: sessionId=abc123
S->>DB: Lookup session "abc123"
DB-->>S: {userId: 42, role: "admin"}
S-->>B: 200 OK (dashboard data)
- The user submits credentials (login form)
- The server validates them against the database
- The server creates a session record and stores it (Redis, PostgreSQL, in-memory)
- The server sends back a session ID in a
Set-Cookieheader - The browser automatically includes this cookie in every subsequent request
- The server looks up the session ID to identify the user
Code Example (Express + Redis)
import express from "express";
import session from "express-session";
import RedisStore from "connect-redis";
import { createClient } from "redis";
const redisClient = createClient({ url: "redis://localhost:6379" });
await redisClient.connect();
const app = express();
app.use(express.json());
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!, // Use a strong, random secret
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Only sent over HTTPS
httpOnly: true, // Not accessible via JavaScript
sameSite: "lax", // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}),
);
// Login
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Regenerate session ID to prevent session fixation attacks
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: "Session error" });
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: "Logged in" });
});
});
// Protected route
app.get("/dashboard", (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Not authenticated" });
}
res.json({ message: `Welcome, user ${req.session.userId}` });
});
// Logout
app.post("/logout", (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: "Logout failed" });
res.clearCookie("connect.sid");
res.json({ message: "Logged out" });
});
});
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Simple mental model, server is the source of truth | Requires server-side storage (memory/Redis/DB) |
| Easy to invalidate, just delete the session | Harder to scale horizontally (need shared session store) |
| Cookies are sent automatically by browsers | Vulnerable to CSRF without SameSite / tokens |
| HttpOnly cookies are immune to XSS token theft | Doesn't work well for mobile apps or third-party APIs |
| Mature ecosystem and battle-tested libraries | Sticky sessions or distributed store adds complexity |
Production Tips
- Always use
HttpOnly,Secure, andSameSitecookie flags - Regenerate session IDs after login to prevent session fixation
- Store sessions in Redis or a database, never in-memory in production (it doesn't survive restarts and doesn't scale across instances)
- Set reasonable expiry times and implement idle timeouts
5. API Key Authentication
If you've ever pasted a key into a .env file and used it to call an API, you know what this is. Long random strings that identify an application or account. Stripe, OpenAI, Twilio, AWS... everyone uses them.
How It Works
sequenceDiagram
participant C as Client / App
participant AG as API Gateway
participant S as API Server
participant DB as Key Store
C->>AG: GET /api/data<br/>X-API-Key: sk_live_abc123...
AG->>DB: Lookup key "sk_live_abc123..."
DB-->>AG: {orgId: 7, plan: "pro",<br/>rateLimit: 1000/min}
AG->>S: Forward request<br/>+ org context
S-->>AG: Response data
AG-->>C: 200 OK (data)
- The developer generates an API key from a dashboard
- The key is sent with every request, usually via a header (
X-API-Key,Authorization: Bearer <key>) or query parameter - The server looks up the key, identifies the caller, and applies rate limits / permissions
Code Example
Generating a secure API key:
import { randomBytes } from "crypto";
function generateApiKey(prefix = "sk_live"): string {
const key = randomBytes(32).toString("base64url"); // 256-bit entropy
return `${prefix}_${key}`;
}
// Example: sk_live_a1b2c3d4e5f6...
Validating API keys (Express middleware):
async function apiKeyAuth(req, res, next) {
const key = req.headers["x-api-key"];
if (!key) {
return res.status(401).json({ error: "API key required" });
}
// Hash the key before lookup to avoid storing plaintext keys
const keyHash = createHash("sha256").update(key).digest("hex");
const record = await db.apiKeys.findByHash(keyHash);
if (!record || record.revokedAt) {
return res.status(403).json({ error: "Invalid or revoked API key" });
}
// Attach the org context for downstream use
req.org = { id: record.orgId, plan: record.plan };
next();
}
Real-World Example: How Stripe Does It
Stripe uses prefixed API keys to make them self-describing:
| Key | Purpose |
|---|---|
pk_test_... | Publishable key (test mode), safe for frontend |
pk_live_... | Publishable key (live mode), safe for frontend |
sk_test_... | Secret key (test mode), backend only |
sk_live_... | Secret key (live mode), backend only |
rk_live_... | Restricted key, limited permissions |
The prefix tells you instantly: is this key safe to expose? Is it test or production? What are its capabilities?
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Extremely simple to implement and use | Static. If leaked, it's compromised until rotated |
| No complex authentication flows | No built-in expiry (must be managed manually) |
| Easy to revoke and regenerate | Identifies the application, not the user |
| Works across every HTTP client and language | Can't carry user-specific context without a lookup |
| Great for rate limiting and usage tracking | Often accidentally committed to Git or exposed in logs |
Production rule: Store API keys hashed (SHA-256) in your database, just like passwords. If your database is breached, attackers shouldn't get usable keys. Also, always use environment variables. Never hardcode keys in source code.
6. JWT & Bearer Token Authentication
JWTs are everywhere, and they're also the most misunderstood auth mechanism out there. Half the blog posts about them are wrong, and the other half skip the parts that actually matter in production.
A JWT is a self-contained token. It carries user data (claims), and it's cryptographically signed so the server can verify it without hitting a database.
How JWT Works
A JWT is made up of three Base64URL-encoded parts, separated by dots:
header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MTIzfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decoded:
// Header
{
"alg": "HS256",
"typ": "JWT"
}
// Payload (claims)
{
"userId": 42,
"role": "admin",
"exp": 1712300000,
"iat": 1712296400
}
// Signature
HMACSHA256(base64url(header) + "." + base64url(payload), secret)
The Bearer Token Flow
sequenceDiagram
participant C as Client (SPA / Mobile)
participant S as Auth Server
participant API as API Server
C->>S: POST /auth/login<br/>{email, password}
S-->>C: 200 OK<br/>{accessToken: "eyJ...", refreshToken: "dGhpcyBpcy4uLg=="}
Note over C: Store tokens<br/>(memory / secure storage)
C->>API: GET /api/profile<br/>Authorization: Bearer eyJ...
Note over API: Verify JWT signature<br/>Check expiry<br/>Extract claims
API-->>C: 200 OK {name: "Alice", role: "admin"}
Note over C: Token expired (401)
C->>S: POST /auth/refresh<br/>{refreshToken: "dGhpcyBpcy4uLg=="}
S-->>C: 200 OK<br/>{accessToken: "eyJ...(new)"}
- User logs in and receives an access token (JWT) and a refresh token
- The access token is sent in the
Authorization: Bearer <token>header - The server verifies the signature and extracts claims (no database lookup needed)
- When the access token expires, the refresh token is used to get a new one
Code Example
Issuing JWTs (Node.js):
import jwt from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
function generateTokens(user: { id: number; role: string }) {
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: "15m" }, // Short-lived
);
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: "7d" }, // Long-lived
);
return { accessToken, refreshToken };
}
Verifying JWTs (Express middleware):
function authenticateJWT(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Token required" });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, ACCESS_SECRET);
req.user = payload; // { userId: 42, role: "admin", exp: ..., iat: ... }
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired" });
}
return res.status(403).json({ error: "Invalid token" });
}
}
HS256 vs RS256: Choosing a Signing Algorithm
| HS256 (Symmetric) | RS256 (Asymmetric) | |
|---|---|---|
| Key | Single shared secret | Private key signs, public key verifies |
| Speed | Faster | Slower |
| Use case | Monolith, single service | Microservices, distributed systems |
| Key distribution | Secret must be shared with all verifiers | Only public key is shared (safe to distribute) |
| Production recommendation | Small apps | Preferred for most production systems |
In a microservice architecture, RS256 is almost always the right choice. Each service only needs the public key to verify tokens, and the private key stays locked down in the auth service.
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Stateless, no server-side session storage | Cannot be revoked mid-flight (until expiry) |
| Scales horizontally with zero coordination | Payload is readable by anyone (Base64, not encrypted) |
| Works across domains (unlike cookies) | Larger than session IDs (~800 bytes vs ~32 bytes) |
| Can carry claims (role, permissions, org) | Token size grows with claims, bloats every request |
| Perfect for SPAs and mobile apps | Must be stored carefully on the client (XSS risk) |
Production rule: Never store sensitive data (passwords, SSNs, secrets) in JWT payloads. They are encoded, not encrypted. Anyone can decode and read them.
7. Access Tokens vs Refresh Tokens
This confused me for a long time. Why do we need two tokens? Can't we just use one? Turns out, no. And the reason is elegant once it clicks.
Why Two Tokens?
The dilemma: short-lived tokens are more secure (less time for an attacker to use a stolen token), but they force users to re-authenticate constantly. Long-lived tokens are convenient but dangerous.
The solution: two tokens with different lifespans and security properties.
| Access Token | Refresh Token | |
|---|---|---|
| Lifespan | Short (5–15 minutes) | Long (7–30 days) |
| Purpose | Authorise API requests | Obtain new access tokens |
| Sent with | Every API request | Only to the /refresh endpoint |
| Storage | Memory (SPA) or secure storage | HttpOnly cookie or secure storage |
| If stolen | Attacker has minutes | Attacker has days/weeks (but can be revoked) |
| Revocable | Not easily (stateless) | Yes (stored server-side) |
The Refresh Flow in Detail
sequenceDiagram
participant C as Client
participant Auth as Auth Server
participant DB as Token Store
C->>Auth: POST /auth/login {email, password}
Auth->>DB: Store refresh token hash
Auth-->>C: {accessToken (15m), refreshToken (7d)}
Note over C: 14 minutes later...<br/>Access token expired
C->>Auth: POST /auth/refresh<br/>{refreshToken}
Auth->>DB: Validate refresh token
DB-->>Auth: Valid ✓
Auth->>DB: Rotate: delete old,<br/>store new refresh token hash
Auth-->>C: {accessToken (new, 15m),<br/>refreshToken (new, 7d)}
Note over C: User clicks "Log out"
C->>Auth: POST /auth/logout<br/>{refreshToken}
Auth->>DB: Delete refresh token
Auth-->>C: 200 OK
Refresh Token Rotation
Always rotate refresh tokens. When a refresh token is used, issue a new refresh token and invalidate the old one. This limits the damage if a refresh token is stolen:
app.post("/auth/refresh", async (req, res) => {
const { refreshToken } = req.body;
// Hash the incoming token and look it up
const tokenHash = createHash("sha256").update(refreshToken).digest("hex");
const stored = await db.refreshTokens.findByHash(tokenHash);
if (!stored || stored.expiresAt < new Date()) {
// If the token was already used (reuse detection), revoke the entire family
if (stored?.usedAt) {
await db.refreshTokens.revokeFamily(stored.familyId);
return res
.status(401)
.json({ error: "Token reuse detected. All sessions revoked." });
}
return res.status(401).json({ error: "Invalid refresh token" });
}
// Mark old token as used
await db.refreshTokens.markUsed(stored.id);
// Issue new token pair
const user = await db.users.findById(stored.userId);
const tokens = generateTokens(user);
// Store new refresh token (same family)
const newHash = createHash("sha256")
.update(tokens.refreshToken)
.digest("hex");
await db.refreshTokens.create({
hash: newHash,
userId: user.id,
familyId: stored.familyId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res.json(tokens);
});
The familyId concept is key. It groups all rotated tokens from a single login. If a token is reused (someone stole the old one), you revoke the entire family, forcing the legitimate user to re-authenticate. This is exactly how Auth0 and other providers implement refresh token rotation.
Where to Store Tokens on the Client
| Storage | XSS Safe? | CSRF Safe? | Recommendation |
|---|---|---|---|
localStorage | No, JS can read it | Yes | Avoid for sensitive tokens |
sessionStorage | No, JS can read it | Yes | Slightly better (clears on tab close) |
| HttpOnly cookie | Yes, JS can't read it | No (without SameSite) | Best for refresh tokens |
| In-memory variable | Yes, cleared on refresh | Yes | Best for access tokens in SPAs |
The recommended pattern for SPAs:
- Store the access token in memory (a JavaScript variable). It disappears on page refresh, but that's fine because you can use the refresh token to get a new one
- Store the refresh token in an HttpOnly, Secure, SameSite=Strict cookie. JavaScript can't access it, and the browser sends it automatically
8. Single Sign-On (SSO)
You log into Gmail. Then you open YouTube, already signed in. Google Docs, signed in. Google Cloud Console, signed in. You entered your password once. That's SSO.
How SSO Works
sequenceDiagram
participant U as User
participant App1 as App 1 (CRM)
participant IdP as Identity Provider<br/>(Okta / Azure AD)
participant App2 as App 2 (Dashboard)
U->>App1: Access CRM
App1-->>U: Redirect to IdP
U->>IdP: Login (email + password + MFA)
IdP-->>U: Redirect back to App1 with token
U->>App1: Authenticated ✓
Note over U: Later, user opens Dashboard
U->>App2: Access Dashboard
App2-->>U: Redirect to IdP
Note over IdP: User already has<br/>an active IdP session
IdP-->>U: Redirect back to App2 with token
U->>App2: Authenticated ✓ (no login prompt!)
The magic: the Identity Provider (IdP) maintains its own session. When a second application redirects you there, the IdP recognises your existing session and immediately issues a token. No password prompt needed.
SSO Protocols
| Protocol | Format | Common In |
|---|---|---|
| SAML 2.0 | XML-based assertions | Enterprise (legacy) |
| OAuth 2.0 + OIDC | JSON/JWT tokens | Modern web and mobile |
| CAS | Ticket-based | Universities, older systems |
Most new implementations use OIDC (OpenID Connect) over SAML because it's simpler, JSON-based, and better suited for SPAs and mobile apps. SAML is still dominant in enterprise environments with existing infrastructure.
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| One login for all applications | Single point of failure (if IdP is down, nothing works) |
| Better security, fewer passwords to manage | Complex to set up initially |
| Centralised user management and de-provisioning | If the SSO account is compromised, all apps are exposed |
| Reduced password fatigue for users | Vendor lock-in with some IdP providers |
| Easier compliance and audit trails | Requires trust relationship between all apps and IdP |
9. OAuth 2.0 & OpenID Connect (OIDC)
Every "Sign in with Google" button, every "Connect your GitHub account" prompt... that's OAuth2 and OIDC under the hood. These two specs power most of the modern web's authentication. They're also where most developers get confused, so let's be precise.
OAuth 2.0: The Authorisation Framework
OAuth 2.0 (RFC 6749) is an authorisation framework (not authentication, that's a critical distinction). It allows a user to grant a third-party application limited access to their resources on another service, without giving away their password.
The four key roles:
| Role | Example |
|---|---|
| Resource Owner | You (the user) |
| Client | The third-party app (e.g., Notion) |
| Authorization Server | Google's auth server |
| Resource Server | Google's API (Gmail, Drive, etc.) |
Authorization Code Flow (Recommended for Web Apps)
This is the most secure OAuth2 flow and the one you should use for server-rendered web applications.
sequenceDiagram
participant U as User
participant App as Your App<br/>(Client)
participant AS as Auth Server<br/>(Google)
participant API as Resource Server<br/>(Google API)
U->>App: Click "Sign in with Google"
App-->>U: Redirect to Google<br/>/authorize?response_type=code<br/>&client_id=...&redirect_uri=...<br/>&scope=openid email profile<br/>&state=xyz123
U->>AS: Login + consent screen
AS-->>U: Redirect to App<br/>/callback?code=AUTH_CODE&state=xyz123
App->>AS: POST /token<br/>{code, client_id, client_secret,<br/>redirect_uri, grant_type}
AS-->>App: {access_token, id_token,<br/>refresh_token, expires_in}
App->>API: GET /userinfo<br/>Authorization: Bearer access_token
API-->>App: {sub, email, name, picture}
- Your app redirects the user to the authorisation server (Google, GitHub, etc.)
- The user authenticates and consents to the requested permissions
- The auth server redirects back to your app with an authorization code
- Your app exchanges the authorization code for tokens on the backend (server-to-server, the code is never exposed to the browser)
- Your app uses the access token to call APIs on behalf of the user
The
stateparameter is critical. It prevents CSRF attacks. Generate a random value, store it in the session, and verify it when the callback arrives.
Authorization Code + PKCE (For SPAs and Mobile Apps)
Public clients (SPAs, mobile apps) can't keep a client_secret safe. Anyone can inspect the source code. PKCE (Proof Key for Code Exchange, pronounced "pixy") solves this:
sequenceDiagram
participant SPA as SPA / Mobile App
participant AS as Auth Server
Note over SPA: Generate random code_verifier<br/>Compute code_challenge =<br/>SHA256(code_verifier)
SPA->>AS: /authorize?response_type=code<br/>&code_challenge=abc...&code_challenge_method=S256
AS-->>SPA: Redirect with ?code=AUTH_CODE
SPA->>AS: POST /token<br/>{code, code_verifier}
Note over AS: Verify: SHA256(code_verifier)<br/>== original code_challenge
AS-->>SPA: {access_token, id_token}
The code_verifier acts as a dynamic secret. Even if an attacker intercepts the authorization code, they can't exchange it without the verifier.
// Generate PKCE challenge pair
import { randomBytes, createHash } from "crypto";
function generatePKCE() {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
const { verifier, challenge } = generatePKCE();
// Store verifier in session/memory, send challenge in /authorize request
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", generateRandomState());
Other OAuth2 Grant Types
| Grant Type | Use Case | Security Level |
|---|---|---|
| Authorization Code | Web apps with a backend | High |
| Authorization Code + PKCE | SPAs, mobile apps | High |
| Client Credentials | Machine-to-machine (no user) | High (for its purpose) |
| Device Code | Smart TVs, CLI tools, IoT | Medium |
| Deprecated, do not use | ||
| Deprecated, do not use |
OpenID Connect (OIDC): Authentication on Top of OAuth2
OAuth2 tells you what someone is allowed to do but not who they are. OIDC adds an identity layer on top of OAuth2.
The key addition is the ID Token, a JWT that contains the user's identity claims:
{
"iss": "https://accounts.google.com",
"sub": "1234567890", // Unique user identifier
"aud": "your-client-id", // Your app's client ID
"exp": 1712300000,
"iat": 1712296400,
"nonce": "n-0S6_WzA2Mj",
"email": "alice@example.com",
"email_verified": true,
"name": "Alice Smith",
"picture": "https://example.com/alice.jpg"
}
OAuth2 vs OIDC at a glance:
| OAuth 2.0 | OIDC | |
|---|---|---|
| Purpose | Authorisation (access delegation) | Authentication (identity verification) |
| Defines | Access tokens | ID tokens (JWT) + UserInfo endpoint |
| Answers | "What can this app do?" | "Who is this user?" |
| Scopes | Custom (e.g., read:repos) | Standard: openid, profile, email |
| Built on | N/A | OAuth 2.0 (extension) |
Key insight: When someone says "Log in with Google", that's OIDC. When you authorise a third-party app to read your Google Drive files, that's OAuth2. In practice, most flows use both together. You authenticate (OIDC) and get authorised (OAuth2) in the same redirect.
Full OIDC Login Example (Next.js + Auth.js)
Here's how simple OIDC login looks in a modern framework:
// auth.ts (Auth.js / NextAuth configuration)
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken;
return session;
},
},
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
// app/login/page.tsx
import { signIn } from "@/auth";
export default function LoginPage() {
return (
<div>
<form
action={async () => {
"use server";
await signIn("google");
}}
>
<button type="submit">Sign in with Google</button>
</form>
<form
action={async () => {
"use server";
await signIn("github");
}}
>
<button type="submit">Sign in with GitHub</button>
</form>
</div>
);
}
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Industry standard, supported everywhere | Complex to implement from scratch |
| User never shares password with your app | Redirect-based UX can confuse users |
| Granular permissions via scopes | Requires proper state/nonce validation for security |
| Access can be revoked without changing passwords | Token management adds client-side complexity |
| PKCE makes it safe for SPAs and mobile | Many moving parts, easy to misconfigure |
10. Identity and Invalidation
Everyone focuses on login. Nobody thinks about logout until production breaks.
Invalidation (revoking access after it's been granted) is the hard part of auth. And identity management across multiple providers is where things get messy fast.
Identity: Who is the User?
Every authentication system needs a stable, unique identifier for each user, one that doesn't change when they update their email or username. This is the "identity."
| Identifier | Good Primary Identity? | Why |
|---|---|---|
| No | Users change emails. Shared accounts. | |
| Username | No | Users rename themselves. |
| UUID / Database ID | Yes | Stable, unique, opaque. |
OIDC sub claim | Yes | Unique per user per provider. |
| Phone number | No | Users change phone numbers. |
In practice, you'll map external identities (OIDC sub from Google, GitHub user ID, etc.) to an internal UUID:
// Link external identity to internal user
interface UserIdentity {
id: string; // Internal UUID (primary identity)
email: string; // For display / communication
identities: {
provider: string; // "google" | "github" | "email"
providerId: string; // External ID (OIDC sub, GitHub user ID)
linkedAt: Date;
}[];
}
// Account linking: user logs in with Google, then also links GitHub
async function linkIdentity(
userId: string,
provider: string,
providerId: string,
) {
const existing = await db.identities.findByProvider(provider, providerId);
if (existing && existing.userId !== userId) {
throw new Error("This account is already linked to another user");
}
await db.identities.create({
userId,
provider,
providerId,
linkedAt: new Date(),
});
}
Invalidation: The Hard Problem
Creating tokens is easy. Invalidating them is where things get interesting, especially for stateless tokens like JWTs.
Session Invalidation (Easy)
Sessions are server-side, so invalidation is straightforward. Just delete the record:
// Invalidate a single session
await redisClient.del(`session:${sessionId}`);
// Invalidate ALL sessions for a user (password change, security breach)
const keys = await redisClient.keys(`session:user:${userId}:*`);
if (keys.length > 0) {
await redisClient.del(keys);
}
JWT Invalidation (Hard)
JWTs are stateless. The server doesn't track them. So how do you "revoke" a JWT before expiry? You have to re-introduce some state:
Strategy 1: Short expiry + refresh token revocation
The simplest approach. Access tokens live for 5–15 minutes. You can't revoke them, but the damage window is small. Revoke the refresh token to prevent new access tokens from being issued.
Strategy 2: Token blocklist (deny list)
Maintain a set of revoked token IDs (jti claim) in Redis. Check it on every request:
async function isTokenRevoked(jti: string): Promise<boolean> {
return (await redisClient.exists(`revoked:${jti}`)) === 1;
}
async function revokeToken(jti: string, expiresIn: number) {
// Store with TTL matching token expiry (auto-cleanup)
await redisClient.setEx(`revoked:${jti}`, expiresIn, "1");
}
// In the auth middleware
function authenticateJWT(req, res, next) {
const payload = jwt.verify(token, SECRET);
if (await isTokenRevoked(payload.jti)) {
return res.status(401).json({ error: "Token has been revoked" });
}
req.user = payload;
next();
}
Strategy 3: Token versioning
Store a tokenVersion on the user record. Include it in the JWT. If the version doesn't match, the token is invalid:
// In the JWT payload
{ userId: 42, tokenVersion: 3 }
// On password change or forced logout
await db.users.update(userId, { tokenVersion: { increment: 1 } });
// In verification middleware
const user = await db.users.findById(payload.userId);
if (user.tokenVersion !== payload.tokenVersion) {
return res.status(401).json({ error: "Token invalidated" });
}
Invalidation Comparison
flowchart TD
A[Need to Invalidate<br/>Authentication?] --> B{What type?}
B -->|Session| C[Delete from store<br/>Redis / DB]
B -->|JWT Access Token| D{Can you wait<br/>for expiry?}
B -->|Refresh Token| E[Delete from DB<br/>+ revoke family]
B -->|API Key| F[Mark as revoked<br/>in DB]
D -->|Yes| G[Short expiry<br/>5-15 min is fine]
D -->|No, need immediate| H{Choose strategy}
H -->|Simple| I[Blocklist in Redis]
H -->|Scalable| J[Token versioning]
style C fill:#10b981,color:#fff
style E fill:#10b981,color:#fff
style F fill:#10b981,color:#fff
style G fill:#f59e0b,color:#fff
style I fill:#7c3aed,color:#fff
style J fill:#7c3aed,color:#fff
When to Invalidate
Build invalidation into these events:
| Event | What to Invalidate |
|---|---|
| User clicks "Log out" | Current session / refresh token |
| User changes password | All sessions and refresh tokens |
| User enables/disables MFA | All sessions and refresh tokens |
| Admin de-provisions user | Everything: sessions, tokens, API keys |
| Suspicious activity detected | All tokens + force re-authentication |
| API key rotation | Old API key (after grace period) |
Putting It All Together: A Decision Tree
Not sure which authentication method to use? Here's a practical decision tree:
flowchart TD
A[What are you building?] --> B{Server-rendered<br/>web app?}
B -->|Yes| C{Need third-party login?}
C -->|Yes| D[OAuth2 / OIDC<br/>+ Session]
C -->|No| E[Session-based auth<br/>with cookies]
B -->|No| F{SPA or<br/>Mobile app?}
F -->|Yes| G[JWT + Refresh Token<br/>OAuth2 + PKCE if 3rd party]
F -->|No| H{Server-to-server<br/>or API?}
H -->|Yes| I{User context<br/>needed?}
I -->|Yes| J[OAuth2 Client Credentials<br/>or JWT]
I -->|No| K[API Key]
H -->|No| L{Internal tool<br/>or script?}
L -->|Yes| M[Basic Auth over TLS<br/>or API Key]
L -->|No| N{Enterprise / multi-app?}
N -->|Yes| O[SSO with OIDC / SAML]
style D fill:#7c3aed,color:#fff
style E fill:#0891b2,color:#fff
style G fill:#7c3aed,color:#fff
style J fill:#0891b2,color:#fff
style K fill:#10b981,color:#fff
style M fill:#f59e0b,color:#fff
style O fill:#7c3aed,color:#fff
Quick Reference: All Methods Compared
| Method | Stateless? | Revocable? | Cross-domain? | User Identity? | Complexity |
|---|---|---|---|---|---|
| Basic Auth | Yes | No (no sessions) | Yes (header) | Yes (per-request) | Very Low |
| Digest Auth | Yes | No | Yes (header) | Yes | Medium |
| Session | No | Yes (delete session) | No (cookies) | Yes | Low |
| API Key | Yes | Yes (revoke key) | Yes (header) | No (identifies app) | Low |
| JWT | Yes | Hard (see strategies) | Yes (header) | Yes (claims) | Medium |
| OAuth2 | Varies | Yes (revoke tokens) | Yes | Limited (access) | High |
| OIDC | Varies | Yes | Yes | Yes (ID token) | High |
| SSO | No (IdP session) | Yes (IdP logout) | Yes | Yes | Very High |
Final Thoughts
Auth looks simple until it isn't. "Just check the password" turns into token rotation, blocklists, PKCE verifiers, and 2 AM incidents where someone's refresh token family got compromised.
A few things I've learned the hard way:
- Don't roll your own crypto. Use battle-tested libraries for hashing, signing, and token generation
- HTTPS everywhere. Without TLS, none of this matters
- Least privilege. Tokens should carry only the permissions they need. Nothing more
- Plan for invalidation from day one. Bolting it on later is painful
- Pick the right tool. Sessions for web apps, JWTs for APIs, OAuth2/OIDC for third-party login. Don't overthink it
If something here saved you from a production mistake or finally made OAuth2 click, it was worth writing.