How to Debug a JWT Token Safely in the Browser
To safely debug a JWT in the browser, use a client-side decoder that never sends your token over the network โ never paste production tokens into random websites. The key things to check are the exp (expiration), iat (issued-at), iss (issuer), and aud (audience) claims. And here's the single most important thing you need to internalize: decoding a JWT is not the same as verifying it. Anyone can decode the payload โ it's just base64 โ but only someone with the secret key can verify the signature is legitimate. I've seen senior engineers get burned by this distinction more times than I can count, so let's walk through everything you actually need to know.
JWT Anatomy: What You're Actually Looking At
Every JWT is three base64url-encoded strings joined by dots. If you've ever stared at one in your browser's DevTools and wondered what all those characters actually mean, here's the breakdown:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYzEyMyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNzE2MjM5MDIyLCJleHAiOjE3MTYyNDI2MjIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6ImFwaS5leGFtcGxlLmNvbSJ9.c2lnbmF0dXJlLWdvZXMtaGVyZQ
Split on the dots and you get three parts:
- Header โ metadata about the token (algorithm, type, key ID)
- Payload โ the claims (who issued it, who it's for, when it expires)
- Signature โ cryptographic proof that the token hasn't been tampered with
The Header
Decode the first segment and you get something like this:
{
"alg": "RS256",
"typ": "JWT",
"kid": "abc123"
}
The alg claim tells you which algorithm was used to sign the token. Common values you'll see in the wild:
- RS256 โ RSA with SHA-256, the most common choice for OAuth/OIDC providers like Auth0, Okta, and Keycloak. Uses asymmetric keys (public/private pair).
- HS256 โ HMAC with SHA-256. Uses a shared secret. If you see this in a multi-service architecture, someone probably made a mistake โ HS256 means every service that verifies tokens needs the shared secret, which is a nightmare to rotate.
- ES256 โ ECDSA with P-256 and SHA-256. Smaller signatures, faster verification. Increasingly common in modern setups.
- none โ The algorithm that launched a thousand CVEs. If you ever see
"alg": "none"in a production token, someone is either attacking you or your JWT library has a catastrophic configuration bug. Seriously, go check your validation logic right now.
The kid (Key ID) is critical for debugging โ it tells you which key from the JWKS endpoint was used. When a token fails verification and you're staring at a kid that doesn't match anything in your provider's JWKS, you've found your problem.
The Payload
This is where all the actionable debugging information lives. A typical decoded payload looks like:
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"iat": 1716239022,
"exp": 1716242622,
"nbf": 1716239022,
"iss": "https://auth.example.com",
"aud": "api.example.com",
"scope": "read:users write:orders"
}
Every one of these claims tells you something useful. sub is the subject โ the user or entity this token represents. iat is when it was issued, exp is when it expires, and nbf is "not before" (the token is invalid before this time). All of these are Unix timestamps in UTC seconds, which we'll get to in a moment.
The Signature
The signature is the third dot-separated segment and it's the only part you can't inspect by decoding โ it's a binary cryptographic blob, base64url-encoded. Its entire purpose is to prove that the header and payload haven't been modified since the token was issued. Without the signing key (or the public key from the JWKS endpoint for RS256/ES256), this is just opaque bytes.
The Golden Rule: Decoded โ Verified
Here is the number one security mistake I see developers make with JWTs, and I need you to read this carefully: anyone can decode a JWT. The payload is just base64 โ there is zero encryption involved.
I've watched developers copy a JWT from their browser's localStorage, paste it into some random jwt.io clone, see the payload rendered as pretty JSON, and think "great, the token is valid." No. What you just did is the equivalent of reading the "TO:" field on an envelope and concluding the letter inside is genuine. The payload is public information. The signature is the only thing that matters for authenticity.
Here's a concrete example. Take this token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZSI6InVzZXIifQ.fake-signature
I can modify the payload to say "role": "admin", re-base64 it, and concatenate it with the original header and any signature I want:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIn0.anything-i-want
This will decode perfectly in any JWT decoder. It will show "role": "admin" in the payload. But no properly configured server will accept it, because the signature won't match โ the server will compute HMAC-SHA256(header.payload, secret) and get a completely different value from "anything-i-want".
This is why you should never paste production tokens into third-party websites. Those sites could be logging every token that hits their server. A stolen access token is a session hijack waiting to happen. Use a decoder that runs entirely in your browser โ like ToolStand's JWT Decoder, which processes everything client-side with zero network requests.
What to Check When Debugging a JWT
When someone in your Slack channel says "my token isn't working," here's the ordered checklist I run through. It solves the problem 90% of the time before I even look at server logs.
1. Check exp First โ It's Usually the Problem
The exp claim is a Unix timestamp in UTC seconds. If your token expired 3 minutes ago, everything else is academic. But here's the gotcha: Unix timestamps don't have timezone information, and your brain probably doesn't think in epoch seconds. A value like 1716242622 is meaningless until you convert it.
I keep ToolStand's Timestamp Converter open in a tab for exactly this reason. Paste 1716242622 and you'll see it's 2026-05-20T22:30:22Z. Now you can actually reason about it.
Quick terminal one-liner if you prefer the command line:
date -d @1716242622 -u
2. Clock Skew and the iat / nbf Trap
This one burns people constantly. Your token has iat: 1716239200 (issued at) and your server's clock says the current time is 1716239190. That's 10 seconds before the token was issued. If your server strictly validates iat, it rejects the token.
Most JWT libraries include a clock skew tolerance โ typically 30 to 60 seconds โ but I've seen misconfigured deployments where it's set to zero. The symptoms are maddening: tokens work, then don't work, then work again, correlated with nothing obvious. Check your clock skew settings. And if you're debugging a token that has nbf (not before), the same rules apply.
Also worth knowing: many identity providers intentionally set iat slightly in the past (5-10 seconds) to account for propagation delay. If you're seeing tokens rejected for iat violations, clock skew is almost certainly your culprit.
3. iss โ Is This Token From Who You Think?
The iss (issuer) claim should match the exact URL of your identity provider. This seems obvious until you're debugging a staging environment and realize the token was issued by https://auth.prod.example.com but your staging API only trusts https://auth.staging.example.com. The token decodes fine. It has reasonable claims. Your API rejects it with a cryptic 401 and you spend 45 minutes staring at the wrong thing.
For OIDC providers like Keycloak, Auth0, and Okta, the iss claim also determines which JWKS endpoint your server fetches keys from. If your server is configured with the wrong issuer URL, it'll fetch the wrong signing keys, and every token will fail signature verification โ even perfectly valid ones.
4. aud โ The Audience Claim That Nobody Checks (Until It Breaks)
The aud claim specifies who the token is intended for. I've seen this be a single string ("api.example.com"), an array (["api.example.com", "mobile-app"]), or completely absent. If your token validator is configured to enforce aud (which it should be) and the value doesn't match, you'll get a rejection with no obvious explanation.
Common failure mode: you have multiple microservices behind the same API gateway. The token's aud is set to "gateway" but your downstream service expects "user-service". Token is valid, signature checks out, but the audience mismatch kills it silently. Always check aud when a token that "should work" doesn't.
5. sub โ Who Are You, Really?
The sub claim is the user identifier. It's supposed to be unique and never reassigned within the issuer's domain. If you're seeing "user not found" errors despite a valid token, decode the sub and verify it exists in your user database. I've debugged cases where an identity provider migration changed sub formats (from UUIDs to integers, or vice versa) and broke every existing session.
Production Debugging Gotchas
Token Size: The 8KB Cookie Limit
If you're storing JWTs in cookies (which you often should, for HttpOnly + Secure protection), you need to know that most browsers enforce a ~4KB per-cookie limit, and the total cookie header per domain is typically around 8KB. I've seen RS256-signed tokens with embedded user profile data balloon past 7KB, leaving no room for other cookies. Everything works in development, fails silently in production.
A quick sanity check: echo -n "your-jwt-here" | wc -c. If it's over 4000 bytes, you need to trim claims or switch to a reference token pattern.
JWKS / Keycloak / Okta: The kid Mismatch
If your token's header contains a kid but your server's cached JWKS doesn't have a matching key, verification fails. This happens most often when:
- The identity provider rotated keys and your server hasn't refreshed its JWKS cache
- You're pointing at the wrong JWKS endpoint (staging vs. production)
- Your provider uses multiple signing keys and your validation logic only checks the first one
The fix: decode the header, grab the kid, then hit your provider's /.well-known/jwks.json endpoint and grep for that key ID. If it's not there, your server needs to re-fetch.
Access Token vs. Refresh Token
This sounds basic, but I've watched engineers spend an hour trying to decode a refresh token as a JWT. Refresh tokens are typically opaque strings โ they're not JWTs at all. They don't have dots, they don't decode, they're just random blobs stored in the authorization server's database. If your "token" doesn't have two dots in it, it's not a JWT.
Conversely, if you're trying to debug why an access token still works despite appearing expired, check if your API gateway has a grace period configured. Some implementations allow tokens up to 60 seconds past exp to account for clock skew. This is reasonable behavior, but it's confusing when you're staring at an expired exp claim and your requests are still going through.
Why Your Expired Token Still Works
Three common explanations, in order of likelihood:
- Clock skew tolerance. Your server allows tokens up to N seconds past expiration. Default is often 30-60 seconds.
- You're misreading the timestamp.
1716242622in your timezone might look like it's in the past, but it's UTC. Check with the timestamp converter. - Token caching. Some API gateways cache token validation results. The cache hasn't expired even if the token has.
The alg: none Vulnerability Check
Quick security hygiene: if you're debugging a JWT implementation (not just a single token), verify your validation library rejects tokens with "alg": "none". Some older JWT libraries would accept unsigned tokens as valid if the algorithm was none, which is catastrophically wrong. Modern libraries (post-2018 or so) are generally safe, but if you're maintaining legacy code, go check. Now.
Safe Debugging Workflow: A Step-by-Step Checklist
Here's the exact workflow I use when someone drops a non-working JWT into a support channel. It solves most problems in under two minutes.
Step 1: Get the Token Locally (Without Exposing It)
Copy the token from your browser's DevTools (Application tab โ Storage โ Local Storage or Cookies), or from your application's debug logs. Do not copy it from a production server's terminal output if that terminal is being screen-shared or logged to an insecure location. Treat access tokens like passwords โ because that's what they are.
Step 2: Use a Client-Side Decoder
Open ToolStand's JWT Decoder, paste the token, and inspect the decoded header and payload. This tool runs entirely in your browser โ your token never leaves your machine. You'll immediately see all claims in readable JSON, color-coded by section.
Step 3: Check exp First
Grab the exp value and run it through the Timestamp Converter. Is it in the future? If it's in the past, the token is expired โ get a new one. If the exp is fine but you're still getting 401s, move to the next step.
Step 4: Verify the Issuer
Check that iss matches the identity provider your API expects. This is especially important in multi-environment setups where staging tokens accidentally get used against production APIs (or vice versa).
Step 5: Check Audience and Scope
Look at aud โ does it match what your API expects? Then check scope/permissions claims (often under scope, permissions, or a custom claim defined by your provider). I've debugged countless "permission denied" errors where the token was perfectly valid but simply didn't include the required scope.
Step 6: If Everything Looks Right But Still Fails
At this point, the problem is likely server-side. Check:
- Is the JWKS endpoint reachable from your server?
- Does the
kidin the header match a key in the JWKS response? - Is your server's clock synchronized? (Run
timedatectlor check NTP) - Are you behind a proxy that's stripping or modifying the Authorization header?
Quick Reference: JWT Claim Cheat Sheet
When you're in the middle of debugging and just need to know what a claim means:
issโ Issuer. Who created this token. Should be a URL likehttps://auth.example.com.subโ Subject. The user or entity. Unique and never reassigned.audโ Audience. Who this token is intended for. Your API should validate this.expโ Expiration. Unix timestamp (UTC seconds). After this, the token is invalid.nbfโ Not Before. Unix timestamp. Token is invalid before this time.iatโ Issued At. Unix timestamp. When the token was created.jtiโ JWT ID. Unique identifier for this token. Useful for revocation lists.scopeโ Space-delimited list of OAuth 2.0 scopes. What this token is allowed to do.
ToolStand Makes JWT Debugging Safe by Default
I built the ToolStand JWT Decoder specifically because I was tired of telling junior engineers "don't paste that into random websites" and watching them do it anyway. It's 100% client-side โ the decoding happens in your browser using the Web Crypto API and vanilla JavaScript. There are no analytics events fired with token data, no server-side logging, no network requests at all after the page loads. Your tokens stay on your machine, period.
Pair it with the Timestamp Converter for instant exp/iat/nbf translation, and you've got everything you need to debug JWTs quickly and safely. Both tools are free, require no account, and work offline once loaded.