How to Debug JWTs — Decode Claims Without Leaking Secrets
API returns 401. The access token “looks fine.” Half the team pastes it into a random website. Stop. Debugging JWTs is mostly reading claims and comparing them to what the resource server expects — and keeping signing material off the public internet.
Anatomy in 30 seconds
A JWT has three Base64URL parts: header.payload.signature.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.signature
Header typically names alg and typ. Payload holds claims (sub, iss, aud, exp, …). Signature proves integrity if you verify with the right key — decoding alone never proves authenticity.
Local-first decode workflow
- Copy the token from DevTools → Application/Storage or the
Authorizationheader (prefer staging). - Decode header + payload with a local tool or a trusted offline script.
- Redact before pasting into tickets; never drop production refresh tokens into chat.
- Compare claims to API config (
issuer,audience, clock skew). - Only then involve signature verification with keys you already trust.
Use an on-site or local JWT decoder when possible. Treat third-party decode sites as hostile for anything beyond throwaway demo tokens.
function decodePart(part) {
const json = atob(part.replace(/-/g, "+").replace(/_/g, "/")
.padEnd(Math.ceil(part.length / 4) * 4, "="));
return JSON.parse(json);
}
const [h, p] = token.split(".");
console.log(decodePart(h), decodePart(p));
Claims checklist that catches real outages
| Claim | Debug question |
|---|---|
exp | Is the token expired? Is server clock skewed? |
nbf | Is “not before” still in the future? |
iss | Does it match the API’s expected issuer URL? |
aud | Single string vs array mismatch? |
sub | Right user? Impersonation bugs? |
| roles/permissions | Custom claim names differ per IdP |
# Compare unix now vs exp
node -e "console.log(Math.floor(Date.now()/1000))"
A token that decodes cleanly can still fail signature, audience, or revocation checks. Decode narrows the search; it does not green-light the request.
Algorithm and verification footguns
alg: none— reject outright on servers.- RS256 vs HS256 confusion — verifying an RSA token with a symmetric secret (or vice versa) fails or, in bad libraries historically, opens attacks. Pin allowed algorithms server-side.
- Kid mismatches — JWKS rotated; API still caches old keys. Check
kidin header against JWKS. - Wrong environment — staging token hit production JWKS.
Log kid, iss, and error reason on auth failures (not the full token).
Browser vs server symptoms
| Symptom | Likely cause |
|---|---|
| 401 after midnight deploys | Clock skew / short TTL |
| Works in Postman | Missing aud check in one path; cookie vs bearer mismatch |
| Intermittent 401 | Load balancer hitting nodes with different JWKS caches |
| CORS + 401 | Error response without CORS headers (orthogonal but confusing) |
Refresh tokens and privacy
Access tokens in memory are easier to reason about; refresh tokens in localStorage are XSS bait. When debugging SPA auth, note where the token lived. If a token leaked into logs or support screenshots, rotate and revoke — do not only “delete the Slack message.”
Hardening while you debug
- Short access-token TTL
- Explicit
aud/issvalidation - JWKS cache with sensible TTL and refresh on unknown
kid - Never log full JWTs at info level
- Prefer opaque session tokens at the browser via BFF when threat models demand it
Minimal repro for backend engineers
Send the failing request with curl, capture status and WWW-Authenticate if present, attach decoded claims (redacted) plus expected issuer/audience from config. That beats “auth broken” in the ticket title.
Bottom line
Decode JWTs to inspect claims, verify signatures only with trusted keys, and keep production tokens off public decoders. Most “JWT bugs” are expiry, audience, issuer, or JWKS rotation — all visible once you read the payload calmly and compare it to server config.