JWT claims your auth team forgets to check (and how each one bit me)
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.
Related across the network
- epoch.tooljo.com — paste the
exp,iat, ornbfclaim to see when it actually fires (Unix seconds vs milliseconds is a common gotcha; the converter auto-detects). - hash.tooljo.com — JWT HS256 is HMAC-SHA-256 under the hood. The hash calculator covers the underlying primitive.
- base64.tooljo.com — JWTs are base64url-encoded. The decoder lets you inspect the raw bytes of a header or payload segment.