JWTs explained: what's inside a token and how to debug it
Open the developer tools on almost any modern web app and you'll find one: a long, opaque-looking string starting with eyJ, sitting in a cookie or riding along in an Authorization header. That's a JSON Web Token, and it's far less mysterious than it looks — three chunks of encoded text joined by dots, two of which anyone can read in seconds. This guide takes a token apart: what each part does, what the standard claims mean, what the signature does and doesn't prove, and how to work through the failures you'll actually meet — without handing a live credential to a stranger's server along the way.
One string, three parts
A JWT is a compact way for one system to hand another a set of signed statements — claims — such as “this is user 42, and this token is good until midnight.” Structurally, every JWT is the same three parts, separated by two dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBZGEiLCJleHAiOjE3NTIxOTIwMDB9.wDDioQfRZhPBcSQf3x_RBv_erAAK5mxoUUPb-kmeXhU
- The header (first part) is a small piece of JSON saying how the token is secured — here
{"alg":"HS256","typ":"JWT"}. - The payload (second part) is the JSON claims: who the token is about, who issued it, who it's for, and when it stops working. Decoded, the one above reads
{"sub":"42","name":"Ada","exp":1752192000}. - The signature (third part) is raw bytes computed over the first two parts, which is what makes the token tamper-evident.
The reason nearly every JWT starts with eyJ is charmingly mundane: that's what {" — the opening of a JSON object — looks like after Base64 encoding.
Base64URL is encoding, not encryption
The header and payload are Base64URL-encoded: the standard Base64 alphabet with two characters swapped (- for +, _ for /) and the trailing = padding dropped, so a token survives being pasted into URLs and HTTP headers. Decoding it requires no key and no secret — it's a reversible transformation any browser can undo in a microsecond.
This is the single most important thing to understand about JWTs: anyone who obtains a token can read everything in it. The signature stops people from changing the payload; it does nothing to hide it. Two rules follow. First, never put secrets in a token — no passwords, no API keys, and no more personal data than the receiving service genuinely needs. Second, a token pasted into a bug report or a screenshot leaks both its contents and a working credential. The payload itself is ordinary JSON, the same format your APIs already speak.
See it yourself: paste a token into the JWT decoder — it splits and decodes the header and payload in your browser and flags whether the token has expired. Nothing you paste leaves the page.
The standard claims, translated
The payload can carry any JSON you like, but RFC 7519 registers a handful of claims with fixed, three-letter names. Six of them do most of the work:
| Claim | Stands for | What it tells you |
|---|---|---|
iss | Issuer | Who created and signed the token — typically your auth server's URL. |
sub | Subject | Who the token is about — the user or account identifier. |
aud | Audience | Who the token is for — the API that should accept it (and others shouldn't). |
exp | Expiration time | The moment the token stops being valid. |
iat | Issued at | When the token was created. |
nbf | Not before | The moment the token starts being valid. |
The three time claims — exp, iat, nbf — are Unix timestamps: a count of seconds since January 1, 1970 UTC. That's why an expiry looks like 1752192000 rather than a date. Paste one into the timestamp converter to see it as a human date in UTC and your local time, or read our guide to Unix timestamps for how those numbers behave.
What the signature actually proves
The signature is computed over the exact bytes of header.payload. With HS256 — the usual choice when one app issues tokens to itself — it's an HMAC-SHA-256: the issuer feeds the first two parts plus a shared secret into a keyed hash function. Verifying means repeating that computation with the same secret and comparing results. A match proves exactly two things: the token was produced by something that knows the secret, and not one character of it has changed since.
Notice what “shared” implies: any service that can verify an HS256 token can also mint one, because verification and signing use the same key. That's fine inside a single application; it gets uncomfortable once several services need to check tokens. That's when asymmetric algorithms like RS256 or ES256 earn their keep — a private key signs, and a public key, which you can hand out freely, verifies.
Equally important is what a valid signature does not prove. It doesn't hide anything, and it doesn't make the token acceptable: an expired token verifies perfectly. Checking the signature is step one; checking exp, aud and iss against what your service expects is the rest of the job, and skipping it is how “valid” tokens end up authorizing the wrong things.
The classic failures, and how to read them
- Token expired. The most common failure by far, and usually not a bug: access tokens are deliberately short-lived — minutes to hours — so a leaked one has a small blast radius. If users hit this constantly, the fix is a working refresh flow, not a longer lifetime.
- Clock skew. A token is rejected as expired — or as “not yet valid” — the moment it's issued. That's two machines disagreeing about what time it is: the issuer's clock runs ahead, so
iatornbflands in the verifier's future. Most libraries accept a configurable leeway (30–60 seconds is conventional); the durable fix is NTP-synced clocks. - Wrong audience. A perfectly valid, unexpired token gets a 401 because its
audsays one API and the service checking it expects another. Common when one identity provider issues tokens for several APIs — you're holding a real ticket to a different theater. - Wrong key or secret. Signature verification fails outright: a staging token sent to production, keys rotated on one side only, or a token mangled in transit — a stray newline or trailing space from copy-paste is enough.
One failure class earned itself a history lesson. The header's alg field announces how the token is signed — and early JWT libraries trusted it. The spec even defines "alg":"none" for contexts where integrity is guaranteed some other way, so an attacker could strip a token's signature, set the algorithm to none, and some verifiers would wave the forgery through. A related trick told the verifier to treat an RSA public key as an HMAC secret by swapping RS256 for HS256. Modern libraries have closed both doors, and the official guidance in RFC 8725 distills the lesson: the verifier decides which algorithms it accepts — never the token.
Never debug with a stranger's server
A JWT is a bearer token: whoever bears it, is — as far as the API can tell — the subject. Until exp passes, a leaked token is a working login that bypasses the password and any two-factor prompt. So pasting a production token into some random “JWT debugger” website is a real risk: if the site decodes on its server, or merely logs what visitors type, you've just mailed someone a live credential.
What makes a client-side decoder different is the data path. Toolkit's JWT decoder does the Base64URL parsing in your own browser, and the optional HS256 signature check runs on the browser's built-in Web Crypto — the token is never transmitted anywhere. That's an inspectable property, not a promise: open your browser's Network tab while you use it, as our in-browser processing guide shows.
The same instinct should apply everywhere: treat tokens like passwords. Keep them out of commits, logs, URLs and chat history. If you genuinely must hand one to a teammate, use a one-time link — the secret is encrypted in your browser and destroyed on first read — rather than leaving it in a chat scrollback forever.
A five-minute debugging checklist
- Count the dots. A JWT has exactly two, making three parts. Anything else means the token was truncated or double-encoded somewhere along the way.
- Decode the header and payload. Note the
alg, then read the claims like a human — is thesubthe user you think it is? - Check the clock claims. Convert
exp,iatandnbfto real dates. Expired? Issued in the future? That's skew. - Check
audandiss. These are exact string matches against your service's configuration — one character off is a rejection. - Verify the signature last, against the key the issuer actually used, with the algorithm your service has pinned.
A closing caveat, because JWTs aren't always the answer: a signed token can't be recalled. It stays valid everywhere until exp, so instant logout or banning requires a server-side deny-list — which quietly reintroduces the shared state JWTs promised to remove. A single web app talking to its own backend is often better served by plain server-side sessions. JWTs earn their keep when several services need to verify identity without phoning a central session store on every request. Like most tools, they're excellent at the job they were designed for and awkward everywhere else.
Related: Unix timestamps and time zones, demystified · How to share a password or secret safely · Why Toolkit runs entirely in your browser