Debug JWT Problems by Decoding the Token
Most 401 mysteries end the moment you read the token: how JWTs are structured, what exp/iat/nbf and the alg field really mean, why decoding is not verifying, and the recurring bugs a two-minute decode exposes.
Prerequisites
- A JWT that's behaving unexpectedly (from an Authorization header, cookie, or log)
- Omnvert JWT Decoder
Step-by-step
- 1
Know the anatomy: header.payload.signature
A JWT is three base64url-encoded segments joined by dots. The header is a small JSON object declaring the token type and signing algorithm (the alg field). The payload is a JSON object of claims — who the token is for, who issued it, when it expires. The signature is computed over the first two segments and proves they weren't altered. Crucially, base64url is an encoding, not encryption: anyone holding a standard (non-encrypted) JWT can read its header and payload with zero secrets. That's the whole basis of debugging by decoding — and also the reason you must never put passwords or sensitive personal data in JWT claims.
- 2
Decode safely in the browser
Tokens are credentials, so where you paste them matters. The Omnvert JWT decoder decodes and verifies entirely client-side using the browser's Web Crypto API — the token is never sent to a server. (Its only optional network request is fetching public keys from a JWKS URL if you ask it to, and you can paste JWKS JSON instead to stay fully offline.) Paste the token and read the decoded header and payload side by side. Even so, treat a pasted production token as exposed-adjacent: prefer tokens from a test user, and rotate anything highly privileged that has passed through clipboards and chat logs.
- 3
Read the time claims: exp, iat, nbf
The three time claims are Unix timestamps — seconds since 1970, not milliseconds. exp is when the token stops being valid; iat is when it was issued; nbf ('not before') is the earliest moment it may be used. The single most common JWT bug is simply an expired token: compare exp to the current time and the mystery often ends there. The second most common is clock skew: if the issuing server's clock runs ahead of the validating server's, a fresh token can arrive with an iat or nbf a few seconds in the future and be rejected as 'not yet valid'. That's why validators commonly allow a small leeway (a minute or less). A related trap: a token generated with milliseconds in exp decodes to a date thousands of years away — a giveaway that some code used the wrong unit.
- 4
Check identity claims: iss, aud, sub
iss (issuer) says which system minted the token, aud (audience) says which system it's intended for, and sub (subject) identifies the user or client. Validators typically reject tokens whose iss or aud doesn't exactly match configuration — and 'exactly' is the operative word. A staging token presented to production fails because iss points at the staging issuer URL. An aud mismatch appears when one identity provider serves several APIs and your client requested a token for the wrong one. Trailing slashes and http-vs-https in issuer URLs are classic: 'https://auth.example.com' and 'https://auth.example.com/' are different strings, and strict validators treat them as different issuers.
- 5
Read the alg field with suspicion
The header's alg declares the signing algorithm: HS256 is a symmetric HMAC (one shared secret both signs and verifies), while RS256 and ES256 are asymmetric (a private key signs, a public key verifies). Two historically important pitfalls live here. First, alg 'none' — the spec allows unsigned tokens, and old or misconfigured libraries have accepted them, meaning an attacker could strip the signature and forge claims; modern libraries reject 'none' unless explicitly enabled, and yours must too. Second, HS256/RS256 confusion: in vulnerable setups where the library let the token's own alg header choose the algorithm, an attacker could take a server that verifies RS256 with a public key, craft an HS256 token, and get the server to use that public key as an HMAC secret — a key everyone has. The defense in both cases is the same: the server must pin its expected algorithm in configuration and never trust the token's header to choose it. For debugging: if the alg you decode isn't the one your server config expects, you've found the bug.
- 6
Internalize that decoding is not verifying
Decoding only reverses base64url — it proves nothing about authenticity. Anyone can construct a JWT with any claims they like; only the signature check, performed with the HMAC secret or the issuer's public key, establishes that the token really came from the issuer unmodified. So a decoder is a reading tool, not a trust tool: use it to inspect what a token says, never to decide whether to believe it. The Omnvert tool can also verify signatures locally if you provide the secret, a public key, or a JWKS — useful for confirming 'is this signature actually valid against the key my server uses?' when you suspect a key mismatch between environments. And the flip side of 'anyone can read it': your tokens' payloads are effectively public to whoever holds them, so claims must never carry secrets.
- 7
Run the five-question triage
With the token decoded, answer in order: (1) Is exp in the past? Expired token — check refresh logic. (2) Are iss and aud exactly what the failing server expects? If not, the token came from the wrong environment or was requested for the wrong API. (3) Is alg what the server is configured for? A mismatch means signing and validation configs have drifted. (4) Are nbf/iat slightly in the future? Clock skew — compare server clocks and add leeway. (5) Does the payload carry the claims (roles, scopes, tenant ID) the endpoint authorizes on? A valid token missing a required scope produces a 403 that looks like an auth failure but is really an authorization gap. This sequence resolves the large majority of 'the token doesn't work' tickets without touching server logs.
A worked example
// header
{ "alg": "RS256", "typ": "JWT", "kid": "key-2026-05" }
// payload
{
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"sub": "user_18274",
"iat": 1782300000,
"exp": 1782303600,
"scope": "orders:read orders:write"
}Reading this token: it was signed with RS256 using the key identified by kid 'key-2026-05' — if the server's key set no longer contains that kid, verification fails even though nothing about the token is wrong; the fix is refreshing the server's cached keys. It's valid for one hour (exp minus iat is 3600 seconds). It authorizes order reads and writes but nothing else, so a request to a billing endpoint returning 403 is behaving correctly. And it was issued by auth.example.com for api.example.com — presented to any other API with strict audience checking, it will be rejected.
A bearer token is exactly what the name says: whoever bears it is authenticated. Don't paste production tokens of privileged accounts into shared chats, bug trackers, or screenshots of your debugging session — the decoded payload is harmless to show, but the full three-segment token is a working key until exp. When you need to share an example, use a token from a disposable test account, or share only the decoded claims.