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:

  1. Header โ€” metadata about the token (algorithm, type, key ID)
  2. Payload โ€” the claims (who issued it, who it's for, when it expires)
  3. 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:

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 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:

  1. Clock skew tolerance. Your server allows tokens up to N seconds past expiration. Default is often 30-60 seconds.
  2. You're misreading the timestamp. 1716242622 in your timezone might look like it's in the past, but it's UTC. Check with the timestamp converter.
  3. 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:

Quick Reference: JWT Claim Cheat Sheet

When you're in the middle of debugging and just need to know what a claim means:

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.