Skip to main content
PProfileMatch

JWT Security: Expiry, Revocation and the Mistakes to Avoid

Backend DevelopmentLesson 4 of 47 min read

What you'll learn

  • Pin the signing algorithm and check issuer, audience and expiry on every token
  • Explain algorithm confusion and the check that prevents it
  • Compare revocation strategies and their cost to statelessness
  • Review JWT code for the most common security mistakes

On this page

  1. A verifier that checks the whole contract
  2. Algorithm confusion, and why the verifier never reads the header to decide
  3. Expiry and revocation
  4. A review checklist, with the symptom of each miss
  5. How this shows up in real projects and interviews

A conference-hall company runs three internal services behind one login: booking, billing and a newsletter tool. All three trust tokens signed by the same auth service. During an incident review, someone realises that a token issued for the low-risk newsletter tool opened the billing API as well. The signature was valid, the token was not expired, and every library call succeeded. Nobody had checked who the token was *for*. This lesson is a tour of such gaps: a valid signature is the beginning of verification, not the end of it.

A verifier that checks the whole contract

The function below accepts configuration from the *service*, not from the token: the algorithm, the key, the expected issuer and audience, the clock, a small clock-skew allowance and a "valid after" timestamp used for revocation. It supports HS256 and RS256 (the public key is the PEM-encoded verification key). The test script after it exercises each defence and prints the outcome.

strict-verify.mjs
import { createHmac, createVerify, timingSafeEqual } from "node:crypto";

const parse = (part) => JSON.parse(Buffer.from(part, "base64url").toString("utf8"));

/**
 * The caller decides the algorithm and key. The token's own header is only
 * ever compared against that decision, never trusted to choose the check.
 */
export function verifyToken(token, { algorithm, key, issuer, audience, now, leeway = 30, validAfter = 0 }) {
  const parts = token.split(".");
  if (parts.length !== 3) throw new Error("malformed token");
  const [headerPart, payloadPart, signaturePart] = parts;
  const signingInput = `${headerPart}.${payloadPart}`;
  const signature = Buffer.from(signaturePart, "base64url");

  const header = parse(headerPart);
  if (header.alg !== algorithm) throw new Error(`unexpected alg: ${header.alg}`);

  let signatureOk;
  if (algorithm === "HS256") {
    const expected = createHmac("sha256", key).update(signingInput).digest();
    signatureOk = signature.length === expected.length && timingSafeEqual(signature, expected);
  } else if (algorithm === "RS256") {
    signatureOk = createVerify("RSA-SHA256").update(signingInput).verify(key, signature);
  } else {
    throw new Error("verifier configured with an unsupported algorithm");
  }
  if (!signatureOk) throw new Error("bad signature");

  const claims = parse(payloadPart);
  if (claims.iss !== issuer) throw new Error("bad issuer");
  const audiences = [].concat(claims.aud ?? []);
  if (!audiences.includes(audience)) throw new Error("bad audience");
  if (typeof claims.exp !== "number") throw new Error("missing exp");
  if (claims.exp <= now - leeway) throw new Error("expired");
  if (typeof claims.nbf === "number" && claims.nbf > now + leeway) throw new Error("not yet valid");
  if (claims.iat < validAfter) throw new Error("revoked (issued before the last security reset)");
  return claims;
}
checks.mjs
import { generateKeyPairSync } from "node:crypto";
import { sign } from "./jwt-lib.mjs";
import { verifyToken } from "./strict-verify.mjs";

const NOW = 1791170000;
const SECRET = "demo-only-secret-at-least-32-characters-long";
const base = { sub: "usr_77", iss: "https://auth.coworking.example", aud: "booking-api", iat: NOW - 60, exp: NOW + 840 };
const config = { algorithm: "HS256", key: SECRET, issuer: base.iss, audience: "booking-api", now: NOW };

const attempts = {
  "valid token": sign(base, SECRET),
  "token minted for another service": sign({ ...base, aud: "billing-api" }, SECRET),
  "token from another issuer": sign({ ...base, iss: "https://evil.example" }, SECRET),
  "no exp claim": sign({ ...base, exp: undefined }, SECRET),
  "expired 10 s ago (inside 30 s leeway)": sign({ ...base, exp: NOW - 10 }, SECRET),
  "expired 5 minutes ago": sign({ ...base, exp: NOW - 300 }, SECRET),
  "issued before the password reset": sign({ ...base, iat: NOW - 4000 }, SECRET),
  "unsigned token (alg none)": [
    Buffer.from('{"alg":"none","typ":"JWT"}').toString("base64url"),
    Buffer.from(JSON.stringify(base)).toString("base64url"),
    "",
  ].join("."),
};

for (const [label, token] of Object.entries(attempts)) {
  try {
    verifyToken(token, { ...config, validAfter: NOW - 3600 });
    console.log(`accepted  ${label}`);
  } catch (err) {
    console.log(`rejected  ${label} -> ${err.message}`);
  }
}

// Algorithm confusion regression test: the service verifies RS256 with a public key.
const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const publicPem = publicKey.export({ type: "spki", format: "pem" });
const hmacSignedWithPublicKey = sign(base, publicPem); // an HS256 token that reuses the public key as its secret
try {
  verifyToken(hmacSignedWithPublicKey, { ...config, algorithm: "RS256", key: publicPem });
  console.log("accepted  HS256 token presented to an RS256 verifier  <-- bug");
} catch (err) {
  console.log(`rejected  HS256 token presented to an RS256 verifier -> ${err.message}`);
}

Output

accepted  valid token
rejected  token minted for another service -> bad audience
rejected  token from another issuer -> bad issuer
rejected  no exp claim -> missing exp
accepted  expired 10 s ago (inside 30 s leeway)
rejected  expired 5 minutes ago -> expired
rejected  issued before the password reset -> revoked (issued before the last security reset)
rejected  unsigned token (alg none) -> unexpected alg: none
rejected  HS256 token presented to an RS256 verifier -> unexpected alg: HS256

Each "rejected" line corresponds to a real incident type. A token minted for billing-api is refused by booking-api through the audience check, which fixes the story at the top. A token from another issuer fails the issuer check, which matters when you accept tokens from more than one identity provider. A token with no exp is rejected rather than treated as eternal. The unsigned token whose header claims alg: none is rejected because the header's algorithm is compared with the one the service configured. Finally, one line shows tolerance on purpose: a token that expired 10 seconds ago is accepted because the verifier allows 30 seconds of clock skew between machines, whereas one that expired five minutes ago is not. Keep that leeway small.

Algorithm confusion, and why the verifier never reads the header to decide

JWT headers say which algorithm signed the token, which tempts libraries into using that field to pick the verification method. That is dangerous in two ways. A verifier that honours alg: none accepts any unsigned token. And a verifier holding an RSA *public* key that also accepts HS256 might use that public key (which is not secret) as an HMAC secret, so anyone could sign valid-looking tokens with it. The defence is the same in both cases: the service decides the algorithm, and the token's alg must merely match.

The last line of the output is a regression test for exactly that: an HS256 token created using the RSA public key as its secret is refused by a verifier that is configured for RS256. Keep a test like this in your suite, and when you use a library, pass the algorithm list explicitly. Here is the equivalent configuration with Python's PyJWT, which I ran against a token of the same shape:

pyjwt_check.py
import time
import jwt  # PyJWT

SECRET = "demo-only-secret-at-least-32-characters-long"
now = int(time.time())
claims = {"sub": "usr_77", "iss": "https://auth.coworking.example", "aud": "booking-api",
          "iat": now, "exp": now + 600}
token = jwt.encode(claims, SECRET, algorithm="HS256")

options = dict(algorithms=["HS256"], issuer="https://auth.coworking.example", audience="booking-api")
print("ok:", jwt.decode(token, SECRET, **options)["sub"])

for label, change in [
    ("wrong audience", {"audience": "billing-api"}),
    ("wrong issuer", {"issuer": "https://evil.example"}),
    ("RS256 only", {"algorithms": ["RS256"]}),
]:
    try:
        jwt.decode(token, SECRET, **{**options, **change})
    except jwt.PyJWTError as err:
        print(f"{label}: {type(err).__name__}")

expired = jwt.encode({**claims, "exp": now - 5}, SECRET, algorithm="HS256")
try:
    jwt.decode(expired, SECRET, **options)
except jwt.PyJWTError as err:
    print("expired:", type(err).__name__)
print("with 30 s leeway:", jwt.decode(expired, SECRET, leeway=30, **options)["sub"])

Output

ok: usr_77
wrong audience: InvalidAudienceError
wrong issuer: InvalidIssuerError
RS256 only: InvalidAlgorithmError
expired: ExpiredSignatureError
with 30 s leeway: usr_77

In Node, the jose library takes the same three options on its jwtVerify call: algorithms, issuer and audience. Whichever library you use, the question to ask in code review is the same: where is the algorithm list, and where are the issuer and audience checks?

Expiry and revocation

A JWT is verified without asking a database, which is its great strength and its great weakness: you cannot reach out and cancel one. Your options trade statelessness for control.

StrategyHow it worksCost
Short access-token lifetimeTokens expire in minutes, so a stolen one dies quicklyMore refresh traffic; revocation is not immediate
Revoke the refresh tokenDelete server-side refresh records; no new access tokens can be issuedExisting access tokens stay valid until they expire
Valid-after timestamp per userStore a time per user and reject tokens whose iat is earlier (used in the test above after a password reset)One lookup per request, usually cached
Denylist by jtiRecord ids of cancelled tokens until their expNeeds a shared fast store such as Redis, and the list grows
Rotate signing keysChange the key (identified by a kid header); tokens signed with the retired key stop verifyingDisruptive unless clients refresh; best for key compromise

Most systems combine the first three: short access tokens, revocable refresh tokens, and a per-user cut-off timestamp for events such as a password change or "log me out everywhere". Notice the pattern: any strategy that gives instant revocation reintroduces some state, and that is a legitimate trade, not a failure. If you need immediate revocation on every request, a plain server-side session may fit better than a JWT.

A review checklist, with the symptom of each miss

  • No audience check. Symptom: a token meant for one service works on another, as in the opening story.
  • No issuer check with multiple identity providers. Symptom: a token from a tenant or partner system is accepted as if your own service issued it.
  • Algorithm taken from the token header. Symptom: unsigned or differently-signed tokens are accepted, and the test suite never noticed because it only used well-formed tokens.
  • Verification disabled "temporarily". Symptom: verify_signature=False or a skipped verify call ships to production, and anyone can set any claim.
  • Sensitive data in the payload. Symptom: a user pastes a token into a support ticket and exposes their own details, or a log shipper stores PII in plain text.
  • Secrets committed or reused. Symptom: the same signing secret is in the repository, in staging and in production, so one leak compromises all three.
  • Generous lifetimes everywhere. Symptom: a token from a lost laptop keeps working for days.
  • Clock skew ignored. Symptom: random 401s right after login on machines whose clocks differ by a few seconds. Allow a small leeway rather than extending token lifetimes.

How this shows up in real projects and interviews

In practice these checks are mostly library options, and the engineering work is making sure they are set, tested and consistent across services. In interviews, "what are the common JWT vulnerabilities?" is a standard security question: a good answer names algorithm confusion and alg: none, missing audience and issuer validation, weak secrets, sensitive payload data, no expiry, and weak revocation, and says what check prevents each. It also helps to say when you would not use a JWT at all, for example a classic server session for a simple monolith where instant logout matters. To see how these ideas fit with broader web-application risks, read the OWASP Top 10 course, and to practise the surrounding skills, browse security-related roles on the jobs board. If you want to revisit the building blocks, start again at JWT Explained: Structure, Signing and Verification.

Try it yourself

Add a jti denylist to strict-verify.mjs: accept a revokedIds Set in the options and reject any token whose jti is in it with the message revoked (token id denied). Then extend checks.mjs with two new cases: a token whose jti is on the list, and the same token with a jti not on the list. Finally write down, in two sentences, which of the revocation strategies in the table you would choose for a mobile banking app and why.

Show a hint

Check the denylist after the signature and claims checks, using claims.jti. When creating the test tokens, remember to include a jti claim in the payload passed to sign.

Key takeaways

  • A valid signature is not enough: also check the algorithm, issuer, audience, exp (and nbf) on every token.
  • The service chooses the algorithm and key; the token's alg header may only be compared against that choice, which blocks alg: none and algorithm confusion.
  • Combine short access tokens, revocable refresh tokens and a per-user valid-after timestamp; instant revocation always needs some server-side state.
  • Keep the payload free of secrets and personal data, because anyone holding the token can read it.
  • Use a maintained library with explicit algorithms, issuer and audience settings, keep tests for each rejection, and keep clock-skew leeway small.

Test yourself

A trick from this lesson's ride: iss = who minted it, aud = who may accept it, exp = until when. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

How do you revoke a JWT?

A JWT is verified without a database call, so it cannot be recalled by itself. Typical approaches are short access-token lifetimes, deleting the refresh token so no new access tokens are issued, storing a per-user timestamp and rejecting tokens issued before it, or keeping a denylist of token ids (jti) until they expire. Each one adds some server-side state in exchange for control.

What is the JWT algorithm confusion attack?

It happens when a verifier lets the token's alg header choose how to verify. An attacker can then present a token marked none, or an HS256 token signed using an RSA public key as the secret, and have it accepted. The prevention is to configure the expected algorithm and key in the service and reject any token whose header does not match.

What claims should I validate in a JWT?

Always verify the signature with a pinned algorithm, then check exp (and nbf if present), iss against your expected issuer and aud against your service's identifier. Allow only a small clock-skew leeway. Also confirm any application-specific requirements, such as a required scope or role, before authorising the request.