Claims and the Checks You Must Perform
Verifying the signature is step one of five. The other four are where real vulnerabilities live.
A valid signature only proves the token was made by someone with the key. It does not prove the token was meant for you, that it is still current, or that it has not been revoked. Each of those is a separate check.
The registered claims
| Claim | Meaning | Check |
|---|---|---|
iss | issuer | equals your expected issuer, exactly |
sub | subject (the user) | present; use it as the identity |
aud | audience | contains your API's identifier |
exp | expiry | in the future (small skew allowed) |
nbf | not before | in the past |
iat | issued at | not absurdly old |
jti | token id | unique; enables denylisting |
Why aud matters so much
Suppose your identity provider issues tokens for a billing API and an admin API. If the admin API does not check aud, a token minted for billing — which a user may legitimately obtain — is accepted by the admin API. This is token confusion, and it is a full privilege escalation caused by one missing comparison.
Clock skew
Servers drift. Allow a small tolerance (30–60 seconds) on exp and nbf, and no more. Anything larger extends the life of every stolen token by exactly that amount.
Custom claims: namespace them
Adding role or tenant is fine, but prefix custom claims (https://abc.com/role) so they can never collide with a future registered claim. And keep them small.
Claims can go stale
A role baked into a 10-minute token is a snapshot from up to 10 minutes ago. Demote an admin and their current token still says admin. For anything that must be immediately accurate, check the database — the token tells you who, your data tells you what they may do right now.
Example
{
"iss": "https://auth.dfg.com",
"sub": "42",
"aud": ["https://dfg.com/api", "https://dfg.com/billing"],
"exp": 1754313000,
"nbf": 1754312400,
"iat": 1754312400,
"jti": "c1f9a3e2-7b44-4c18-9d0e-51a2f6b8c7d3",
"scope": "orders:read orders:write",
"https://abc.com/tenant": "acme-corp"
}When to use it
- An admin API rejects a token minted for the billing API because the aud claim does not list it, preventing a cross-service privilege escalation.
- A multi-tenant SaaS carries a namespaced tenant claim so every query is scoped without an extra lookup, while permissions are still read live from the database.
- A demoted administrator loses access within one token lifetime because irreversible actions re-check the role in the database rather than trusting the claim.
More examples
All five checks, explicitly
Most JWT libraries only apply the issuer and audience checks if you pass them in. Omitting the options is not a warning — it is silently weaker verification.
import { jwtVerify } from 'jose';
const EXPECTED_ISSUER = 'https://auth.dfg.com';
const MY_AUDIENCE = 'https://dfg.com/api';
export async function verify(token) {
// 1. signature + 2. exp/nbf + 3. iss + 4. aud — all enforced by the library
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'],
issuer: EXPECTED_ISSUER,
audience: MY_AUDIENCE,
clockTolerance: 30,
maxTokenAge: '1h', // reject absurdly old iat even if exp is generous
});
// 5. revocation — the one no library can do for you
if (payload.jti && await denylist.has(payload.jti)) {
throw new Error('token_revoked');
}
return payload;
}
// ❌ What a rushed implementation looks like — and it verifies fine:
// const payload = jwt.verify(token, publicKey); // no issuer, no audience
// A token from ANY service sharing this key is now accepted here.Claims for identity, database for permission
Pick the check per route rather than globally: reading a report from a stale claim is harmless, deleting an account from one is not.
// The token tells you WHO. It is a snapshot, so treat it that way.
app.get('/api/reports', bearerAuth, async (req, res) => {
// Cheap, read-only, tolerant of a few minutes of staleness → claim is fine
if (!req.user.scopes.includes('reports:read')) return res.sendStatus(403);
res.json(await reportsFor(req.user.id));
});
// Irreversible and high value → re-check live state
app.delete('/api/accounts/:id', bearerAuth, async (req, res) => {
const actor = await db.users.findById(req.user.id);
if (!actor || actor.role !== 'admin' || actor.suspendedAt) {
return res.status(403).json({ error: 'insufficient_permissions' });
}
await db.accounts.softDelete(req.params.id, { by: actor.id });
res.sendStatus(204);
});
Discussion