Skip to content
100% in your browser. Nothing you paste is uploaded — all processing runs locally. Read more →

JWT claims your auth team forgets to check (and how each one bit me)

5 min read #jwt #authentication #security #tokens

A signed JWT proves that the issuer wrote it. That’s all. It does not, by itself, prove the token is meant for your service, that it hasn’t been revoked, that it’s still valid this minute, or that it’s actually about the user it claims to be about.

The standard claims (iss, sub, aud, exp, nbf, iat, jti) exist to fill those gaps. Production code I’ve audited usually checks two of them. Here’s why all seven matter, with real war stories.

exp (expiry) — usually checked

The token expires at this Unix timestamp. Past exp ⇒ reject.

Almost every library checks this by default. The bug to watch for: clock skew between issuer and verifier. A token issued one minute ago on the issuer’s clock can look “future-issued” to a verifier whose clock is 90 seconds slow. Add a small leeway window (typically 30–60s) on both exp and nbf checks.

iss (issuer) — frequently skipped

Identifies who signed the token. Should match the URL of the issuer you trust.

War story: a SaaS used Auth0 for customer auth and a different Auth0 tenant for admin auth. Both used HS256. The signing keys were stored in secret managers, but a config bug meant the customer-tenant key got deployed to the admin verifier. Customer-issued tokens were being accepted on the admin API. Checking iss === "https://admin.example.com/auth" would have caught it — both tenants signed with their own URL as the issuer.

Always check iss against the exact issuer URL you trust.

aud (audience) — frequently skipped

Identifies who the token is for. If you’re verifying a token, your service should be in the aud claim.

War story: a microservice got handed a token that was a perfectly valid customer-portal session token. The service didn’t check aud. It treated the customer-portal token as auth to call its admin API. The customer-portal team had no idea their token format had been “borrowed” — they assumed each service issued its own.

Always check aud matches your service identifier.

nbf (not-before) — frequently skipped

The token isn’t valid until this time. Useful for tokens that should only become valid in the future (e.g., a delayed-action grant).

Most JWT libraries check this if present. The bug to watch for: same as exp — clock skew. Add the leeway.

iat (issued-at) — usually informational

When the token was created. By itself, not a security check — but useful for session policy: “force re-auth if token is older than 24h, even if exp is later.”

War story: a long-lived JWT (30-day exp) used as a session token remained valid even after the user changed their password. The fix was to check iat against a per-user “tokens issued before this date are revoked” timestamp stored in the user record. When a user changes password, bump their min-iat. Pre-existing tokens fail validation without needing a revocation list.

jti (JWT ID) — almost always skipped

A unique identifier for this specific token. Useful if you need revocation: keep a list of revoked jtis in a small bloom filter or DB table; reject any token whose jti matches.

War story: a fintech needed to invalidate tokens when an account was compromised. Without jti and a denylist, they had to wait for the 30-min exp to expire on every active token. With a jti denylist, revocation is instant.

If you don’t need revocation, you don’t need jti. But many systems discover they need it after the first breach.

sub (subject) — frequently misunderstood

The user (or principal) the token is about. The string in sub should be your stable user ID, never the user’s email or username.

War story: a system used sub: "user@example.com". When the user changed their email, the next token had a different sub, and a foreign-key in the audit log silently broke (orphaned rows pointing to the old sub).

Always use a stable, immutable user ID for sub. Email is mutable. Username is mutable. UUIDs and integer IDs are not.

A complete verification checklist

function verifyJwt(token: string, opts: {
  expectedIssuer: string;
  expectedAudience: string;
  jwksOrSecret: ...;
  leewaySeconds?: number;
  revoked?: (jti: string) => Promise<boolean>;
  minIatForUser?: (sub: string) => Promise<number | null>;
}) {
  const decoded = await verifySignature(token, opts.jwksOrSecret);
  const now = Math.floor(Date.now() / 1000);
  const leeway = opts.leewaySeconds ?? 60;

  if (decoded.exp + leeway < now) throw "expired";
  if (decoded.nbf && decoded.nbf > now + leeway) throw "not yet valid";

  if (decoded.iss !== opts.expectedIssuer) throw "wrong issuer";

  const audMatches = Array.isArray(decoded.aud)
    ? decoded.aud.includes(opts.expectedAudience)
    : decoded.aud === opts.expectedAudience;
  if (!audMatches) throw "wrong audience";

  if (decoded.jti && opts.revoked && (await opts.revoked(decoded.jti))) {
    throw "revoked";
  }

  if (opts.minIatForUser) {
    const minIat = await opts.minIatForUser(decoded.sub);
    if (minIat != null && decoded.iat < minIat) throw "stale token";
  }

  return decoded;
}

The signature check alone is the first line of defense, not the only one. Everything else above is what makes a JWT a session credential rather than a signed-but-replayable artifact.

Paste your tokens into the JWT decoder — it shows every claim side-by-side with what the spec says, so you can spot what’s missing in your own auth flow before an attacker does.