How to Debug JWTs — Decode Claims Without Leaking Secrets

(Updated: July 16, 2026 ) JWT API Authentication Security

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

  1. Copy the token from DevTools → Application/Storage or the Authorization header (prefer staging).
  2. Decode header + payload with a local tool or a trusted offline script.
  3. Redact before pasting into tickets; never drop production refresh tokens into chat.
  4. Compare claims to API config (issuer, audience, clock skew).
  5. 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

ClaimDebug question
expIs the token expired? Is server clock skewed?
nbfIs “not before” still in the future?
issDoes it match the API’s expected issuer URL?
audSingle string vs array mismatch?
subRight user? Impersonation bugs?
roles/permissionsCustom 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 kid in 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

SymptomLikely cause
401 after midnight deploysClock skew / short TTL
Works in PostmanMissing aud check in one path; cookie vs bearer mismatch
Intermittent 401Load balancer hitting nodes with different JWKS caches
CORS + 401Error 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 / iss validation
  • 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.