← Back to the library
SecurityApplied · 9 min

Logged-out users stay logged in, or tokens can be forged

“Logout clears the client but the old token keeps working for days, a disabled account still calls the API, or a review finds the server trusts decoded claims or accepts whatever algorithm the token header names.”

THE PRINCIPLE

Never let the token choose how it is verified, and never ship a long-lived bearer token you cannot revoke.

FIRST MOVES

  1. Find every decode() or manual base64 parse used for authorization; replace it with verify() and treat unverified claims as untrusted input.
  2. Pin accepted algorithms in code, bind each key to one algorithm, never mix HMAC and public-key algorithms, and reject alg: none.
  3. Validate iss, aud and exp on every request; add an explicit typ so an ID token or another service's token is not accepted as an access token.
  4. Keep access tokens to minutes. Put revocation on rotating refresh tokens or a server session; for urgent cases deny by jti until exp.
  5. In browsers, prefer a backend-for-frontend with HttpOnly, Secure, SameSite cookies over tokens in localStorage, which any script can read.

TOOLS: THEN → NOW

Long-lived JWT as the whole sessionShort-lived access tokens with revocable rotating refresh tokens, or a plain server session
decode() and trust the claimsVerification with pinned algorithms, issuer and audience (jose, RFC 8725 practices)
Bearer tokens in localStorageHttpOnly cookies set by a backend-for-frontend

PATTERN SNAPSHOT

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));

export async function authenticate(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    algorithms: ["ES256"], // pinned in code, never read from the token header
    issuer: "https://auth.example.com",
    audience: "https://api.example.com",
    typ: "at+jwt", // RFC 9068 access tokens; match the typ your issuer sets
    maxTokenAge: "15m",
    requiredClaims: ["sub", "jti", "exp"],
  });
  if (await revokedTokens.has(payload.iss!, payload.jti!)) throw new Error("revoked");
  return payload;
}

CLOSE THE AI. EXPLAIN THIS.

A user changes their password because their laptop was stolen. With your current token design, how long can the thief's token still call the API, and what shortens that?

HOW IT WORKS UNDERNEATH

SOURCES

Guide reviewed