The registered claims, decoded
| Claim | Name | What to check |
|---|---|---|
iss | Issuer | Matches the auth server you expect |
sub | Subject | The user/principal ID — stable across sessions |
aud | Audience | Contains your service; reject tokens meant for others |
exp | Expiration | Seconds (not ms) since epoch; in the future |
nbf | Not before | Token invalid until this time |
iat | Issued at | Useful for measuring token age |
jti | JWT ID | Unique ID, enables revocation lists |
The most commonly skipped check in real codebases is aud — without it, a token issued for one microservice can be replayed against another service that shares the same signing key.
Debugging the three usual failure modes
Signature invalid: you’re verifying with the wrong key (rotated keys, wrong environment), the wrong algorithm family (HS256 secret used against an RS256 token), or the token was truncated in transit — check for a missing final segment after the second dot.
Token rejected as malformed: JWTs use base64url (- and _) without padding; a token that went through a strict standard-Base64 decoder somewhere in your pipeline gets corrupted. URL-encoding a token inside a query string can also mangle it.
Claims look wrong: decode here and read the raw payload. A surprising number of “auth bugs” are just the issuer putting a claim under a different name (userId vs sub) or nesting custom claims under a namespace URL, as Auth0 and others do.
Token hygiene for builders
Keep payloads small — JWTs travel on every request, and a kilobyte of user profile in the token is a kilobyte of header overhead per call. Set expiry short (minutes for access tokens) and pair with refresh tokens; long-lived bearer tokens are stolen-credential time bombs. And never put secrets in the payload: a JWT is signed, not encrypted — anyone holding it can read everything, exactly as this decoder demonstrates.