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

ClaimMeaningCheck
ississuerequals your expected issuer, exactly
subsubject (the user)present; use it as the identity
audaudiencecontains your API's identifier
expexpiryin the future (small skew allowed)
nbfnot beforein the past
iatissued atnot absurdly old
jtitoken idunique; 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

Example · json
{
  "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.

Example · javascript
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.

Example · javascript
// 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

  • Be the first to comment on this lesson.