Uttir
By Uttir 5 min read

How to Debug JWT Tokens: A Practical Guide for Developers

Learn what JWTs are made of, how to decode and verify them, the most common mistakes developers make, and how to inspect a token in your browser without sending it anywhere.

To debug a JWT, decode its three base64url parts (header, payload, signature) and inspect the payload for the claims you care about: sub, exp, iat, iss, aud. Use a JWT decoder that runs in your browser so the token never leaves your machine. Never paste a production token into a third-party website — even a quick decode transmits it.

If you build anything with modern web APIs, you have encountered a JSON Web Token. They are the default credential format for OAuth 2.0, OpenID Connect, and most modern single-page apps. The problem: they are opaque blobs of text that look like garbage, and when something goes wrong with authentication — the dreaded 401, a redirect loop, a user mysteriously "logged out" — debugging them is a chore unless you know what is actually inside the token and how to read it.

This guide walks through what a JWT is, how to decode one safely, the claims that actually matter for debugging, and the mistakes that trip up even experienced developers.

What a JWT actually is

A JWT is three Base64URL-encoded strings joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Each part decodes to a JSON object:

  • Header — metadata about the token: which algorithm was used to sign it (alg), which type of token it is (typ), sometimes the key id (kid).
  • Payload — the actual claims. This is the part you care about when debugging. Standard claims like sub, iat, exp, iss, aud live here, alongside whatever custom claims your app added.
  • Signature — a hash of (header + "." + payload) using the secret. This is what proves the token wasn't tampered with. The signature is not human-readable, and you should never need to inspect it during debugging.

Base64URL is the same as standard Base64 but with - and _ instead of + and /, and no padding. This makes the token safe to use in URLs without escaping. You can decode it with a Base64 decoder if you want, or skip straight to a JWT decoder that does all three parts at once.

How to decode a JWT in your browser (safely)

The single most important rule: never paste a production token into a third-party website. JWTs are bearer credentials — anyone who has the token can impersonate the user until it expires. If the token is from your production environment and you paste it into jwt.io, you have just handed your session to that site.

The safe approach is to decode the token locally. A browser-based JWT decoder parses the token with JavaScript, so the token never leaves your machine. Open DevTools, look at the Authorization header or the cookie where the token lives, copy the value, and paste it into a decoder that runs entirely in your browser.

For an HMAC-signed token (the most common type), you can also verify the signature locally if you have the secret — useful for confirming the token was issued by your service and not someone else. The verification is the same code your backend runs on every request.

The claims that actually matter for debugging

When something is wrong with a JWT, the cause is almost always in one of these claims:

exp — expiration time

The single most common cause of "I keep getting logged out" bugs. exp is a Unix timestamp in seconds (not milliseconds — a frequent bug) marking when the token stops being valid. If your client is checking Date.now() / 1000 against exp and the difference is negative, the token is expired and the client should refresh. If you set token lifetimes too short, users get logged out mid-session. Too long, and a stolen token has a long shelf life. A good default is 15–60 minutes for access tokens, with a separate long-lived refresh token.

iat — issued at

When the token was created. Useful for two things: distinguishing "this is a brand-new token" from "this is a freshly refreshed token from an old session", and diagnosing clock skew. If your auth server and your client disagree on the time by more than a minute, you will see intermittent failures. Set the client to use the server's Date header on the first request, then use that offset locally.

nbf — not before

The token is invalid before this time. Rarely used, but if present, it is usually the cause of "my token is brand new and the server still rejects it" bugs. Check that the server and client clocks are aligned.

iss — issuer

Who issued the token. The server should reject tokens from unknown issuers. If you are seeing 401s and the issuer looks right, check that you are pointing at the correct environment (staging vs. production) — the auth server's URL changes, but the claim name stays the same.

aud — audience

Who the token is intended for. A token issued for API A should not be accepted by API B, even if both are signed by the same auth server. The aud claim is a list of allowed audiences. If your client is sending a token to the wrong API, the right fix is to request a token with the correct audience, not to weaken the audience check on the server.

sub — subject

Who the token represents — usually a user ID. If sub is missing or wrong, the user is being misidentified, which shows up as "I am logged in but I see someone else's data" or "every action is unauthorized".

Common JWT mistakes

1. The none algorithm attack

If your server accepts tokens with alg: "none" (no signature), an attacker can craft a token with whatever claims they want and your server will accept it. This was a real vulnerability in many JWT libraries circa 2015 and the lesson still applies: never trust the alg header. Whitelist the algorithms your server expects (typically HS256 or RS256) and reject anything else.

2. Algorithm confusion (HS256 vs RS256)

Related: an attacker takes a token signed with RS256 (asymmetric — public/private key) and re-signs it with HS256 (symmetric — single secret) using the public key as the HMAC secret. If the server uses the same verification path for both algorithms, the attacker has just forged a valid token. Modern libraries fix this, but it is worth checking.

3. Storing tokens in localStorage

Convenient, but any XSS bug in your app exfiltrates the token. Use HttpOnly cookies for refresh tokens. If you must use localStorage (e.g. for a pure SPA with no backend), accept the risk and add a strong Content Security Policy to limit the damage.

4. Confusing seconds and milliseconds

The JWT spec uses seconds for exp, iat, and nbf. JavaScript's Date.now() returns milliseconds. Multiply by 1000 (or divide by 1000 on the way in) and you will save yourself hours of debugging.

A quick debugging workflow

When a user reports an auth issue, the fastest path is:

  1. Ask the user (or your logs) for the token. In a browser, this is usually in localStorage, sessionStorage, or an HttpOnly cookie. For server-to-server calls, it's the Authorization: Bearer … header.
  2. Paste it into a local JWT decoder.
  3. Check exp first — it is expired in 70% of cases.
  4. Check iss and aud — wrong environment is the next most common.
  5. Check sub — make sure it is the user you think it is.
  6. Only if all of those check out: look at the signature. If you have the secret, verify it locally. If you don't, the issue is on the server side.

JWTs look opaque, but the format is intentionally simple: three base64-encoded JSON objects, signed as a unit. Once you know what to look for, the answers are usually right there in the payload. Use a browser-based JWT decoder, keep your tokens out of third-party tools, and the next 401 will be a five-minute fix instead of a half-day rabbit hole.

#jwt#security#developer-tools#authentication#api

New tools and guides, once a week

One short email when something new ships. No tracking, no images, unsubscribe with one click.