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:

MethodBest ForStatefulness
Basic AuthInternal tools, simple scriptsStateless
Digest AuthLegacy systems needing Basic improvementStateless
Session-basedTraditional web appsStateful (server)
API KeyService-to-service, public APIsStateless
JWT / Bearer TokenSPAs, mobile apps, microservicesStateless
OAuth2Third-party access delegationDepends on grant
OIDCFederated login ("Sign in with Google")Depends on flow
SSOEnterprise / multi-app environmentsStateful (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)
  1. The client makes a request without credentials
  2. The server responds with 401 Unauthorized and a WWW-Authenticate: Basic header
  3. The client re-sends the request with an Authorization header containing Basic <base64(username:password)>
  4. 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

AdvantagesDisadvantages
Dead simple to implementCredentials sent with every request
Supported by every HTTP clientBase64 is encoding, not encryption. Trivially decoded
No session state neededNo built-in logout mechanism
Great for quick internal toolsVulnerable 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

AdvantagesDisadvantages
Password never sent in plaintextComplex to implement correctly
Nonce prevents replay attacksServer must store passwords in a reversible format
Better than Basic without TLSRelies on MD5 which is cryptographically broken
Widely supported in HTTP clientsLargely 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)
  1. The user submits credentials (login form)
  2. The server validates them against the database
  3. The server creates a session record and stores it (Redis, PostgreSQL, in-memory)
  4. The server sends back a session ID in a Set-Cookie header
  5. The browser automatically includes this cookie in every subsequent request
  6. 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

AdvantagesDisadvantages
Simple mental model, server is the source of truthRequires server-side storage (memory/Redis/DB)
Easy to invalidate, just delete the sessionHarder to scale horizontally (need shared session store)
Cookies are sent automatically by browsersVulnerable to CSRF without SameSite / tokens
HttpOnly cookies are immune to XSS token theftDoesn't work well for mobile apps or third-party APIs
Mature ecosystem and battle-tested librariesSticky sessions or distributed store adds complexity

Production Tips

  • Always use HttpOnly, Secure, and SameSite cookie 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)
  1. The developer generates an API key from a dashboard
  2. The key is sent with every request, usually via a header (X-API-Key, Authorization: Bearer <key>) or query parameter
  3. 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:

KeyPurpose
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

AdvantagesDisadvantages
Extremely simple to implement and useStatic. If leaked, it's compromised until rotated
No complex authentication flowsNo built-in expiry (must be managed manually)
Easy to revoke and regenerateIdentifies the application, not the user
Works across every HTTP client and languageCan't carry user-specific context without a lookup
Great for rate limiting and usage trackingOften 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)"}
  1. User logs in and receives an access token (JWT) and a refresh token
  2. The access token is sent in the Authorization: Bearer <token> header
  3. The server verifies the signature and extracts claims (no database lookup needed)
  4. 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)
KeySingle shared secretPrivate key signs, public key verifies
SpeedFasterSlower
Use caseMonolith, single serviceMicroservices, distributed systems
Key distributionSecret must be shared with all verifiersOnly public key is shared (safe to distribute)
Production recommendationSmall appsPreferred 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

AdvantagesDisadvantages
Stateless, no server-side session storageCannot be revoked mid-flight (until expiry)
Scales horizontally with zero coordinationPayload 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 appsMust 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 TokenRefresh Token
LifespanShort (5–15 minutes)Long (7–30 days)
PurposeAuthorise API requestsObtain new access tokens
Sent withEvery API requestOnly to the /refresh endpoint
StorageMemory (SPA) or secure storageHttpOnly cookie or secure storage
If stolenAttacker has minutesAttacker has days/weeks (but can be revoked)
RevocableNot 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

StorageXSS Safe?CSRF Safe?Recommendation
localStorageNo, JS can read itYesAvoid for sensitive tokens
sessionStorageNo, JS can read itYesSlightly better (clears on tab close)
HttpOnly cookieYes, JS can't read itNo (without SameSite)Best for refresh tokens
In-memory variableYes, cleared on refreshYesBest 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

ProtocolFormatCommon In
SAML 2.0XML-based assertionsEnterprise (legacy)
OAuth 2.0 + OIDCJSON/JWT tokensModern web and mobile
CASTicket-basedUniversities, 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

AdvantagesDisadvantages
One login for all applicationsSingle point of failure (if IdP is down, nothing works)
Better security, fewer passwords to manageComplex to set up initially
Centralised user management and de-provisioningIf the SSO account is compromised, all apps are exposed
Reduced password fatigue for usersVendor lock-in with some IdP providers
Easier compliance and audit trailsRequires 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:

RoleExample
Resource OwnerYou (the user)
ClientThe third-party app (e.g., Notion)
Authorization ServerGoogle's auth server
Resource ServerGoogle's API (Gmail, Drive, etc.)

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}
  1. Your app redirects the user to the authorisation server (Google, GitHub, etc.)
  2. The user authenticates and consents to the requested permissions
  3. The auth server redirects back to your app with an authorization code
  4. Your app exchanges the authorization code for tokens on the backend (server-to-server, the code is never exposed to the browser)
  5. Your app uses the access token to call APIs on behalf of the user

The state parameter 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 TypeUse CaseSecurity Level
Authorization CodeWeb apps with a backendHigh
Authorization Code + PKCESPAs, mobile appsHigh
Client CredentialsMachine-to-machine (no user)High (for its purpose)
Device CodeSmart TVs, CLI tools, IoTMedium
ImplicitSPAs (old way)Deprecated, do not use
Resource Owner PasswordFirst-party appsDeprecated, 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.0OIDC
PurposeAuthorisation (access delegation)Authentication (identity verification)
DefinesAccess tokensID tokens (JWT) + UserInfo endpoint
Answers"What can this app do?""Who is this user?"
ScopesCustom (e.g., read:repos)Standard: openid, profile, email
Built onN/AOAuth 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

AdvantagesDisadvantages
Industry standard, supported everywhereComplex to implement from scratch
User never shares password with your appRedirect-based UX can confuse users
Granular permissions via scopesRequires proper state/nonce validation for security
Access can be revoked without changing passwordsToken management adds client-side complexity
PKCE makes it safe for SPAs and mobileMany 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."

IdentifierGood Primary Identity?Why
EmailNoUsers change emails. Shared accounts.
UsernameNoUsers rename themselves.
UUID / Database IDYesStable, unique, opaque.
OIDC sub claimYesUnique per user per provider.
Phone numberNoUsers 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:

EventWhat to Invalidate
User clicks "Log out"Current session / refresh token
User changes passwordAll sessions and refresh tokens
User enables/disables MFAAll sessions and refresh tokens
Admin de-provisions userEverything: sessions, tokens, API keys
Suspicious activity detectedAll tokens + force re-authentication
API key rotationOld 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

MethodStateless?Revocable?Cross-domain?User Identity?Complexity
Basic AuthYesNo (no sessions)Yes (header)Yes (per-request)Very Low
Digest AuthYesNoYes (header)YesMedium
SessionNoYes (delete session)No (cookies)YesLow
API KeyYesYes (revoke key)Yes (header)No (identifies app)Low
JWTYesHard (see strategies)Yes (header)Yes (claims)Medium
OAuth2VariesYes (revoke tokens)YesLimited (access)High
OIDCVariesYesYesYes (ID token)High
SSONo (IdP session)Yes (IdP logout)YesYesVery 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:

  1. Don't roll your own crypto. Use battle-tested libraries for hashing, signing, and token generation
  2. HTTPS everywhere. Without TLS, none of this matters
  3. Least privilege. Tokens should carry only the permissions they need. Nothing more
  4. Plan for invalidation from day one. Bolting it on later is painful
  5. 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.