// IT TOOLS & CALCULATORS
| 100+ TOOLS
📚 JWTS EXPLAINED: WHAT'S ACTUALLY INSIDE A TOKEN
Developer guide · 8 Oct 2026 · 6 min read

A JWT is three Base64URL-encoded chunks separated by dots, and the middle one — the payload, the part carrying the actual claims about who the user is — isn't encrypted. It's just encoded, which is reversible by anyone with a text editor, not a security boundary at all. That single fact resolves most of the confusion around JWTs: the question is never "can someone read this," because the answer is always yes. The question is "can someone change it without getting caught," and the answer to that depends entirely on the third part, the signature, actually being checked.

Three parts, three jobs

A JWT's structure is header.payload.signature, each segment Base64URL-encoded independently and joined with periods. The header is a small JSON object declaring the signing algorithm (alg) and token type (typ) — typically something like {"alg":"HS256","typ":"JWT"}. The payload is a JSON object of claims: standard ones like sub (subject, usually a user ID), exp (expiry, a Unix timestamp), iat (issued-at), iss (issuer), alongside whatever custom claims an application adds — roles, permissions, tenant IDs. The signature is computed over the first two parts concatenated with a dot, run through the algorithm named in the header, using a secret or private key only the issuing server holds. Decoding the header and payload requires nothing more than reversing Base64URL — paste any JWT into a decoder and both are immediately readable JSON, which is by design, not a flaw to patch. One more header field worth knowing: kid (key ID), which servers that rotate signing keys use to tell the verifier which specific key to check the signature against, since an old token signed before a rotation still needs to validate against the key that was active when it was issued.

Readable isn't the problem — unverified writes are

Because the payload is just encoded, not encrypted, a JWT should never carry anything genuinely secret — a password, a full credit card number, a private API key — because anyone who intercepts or is handed the token can read every claim in it directly. What a JWT's signature actually guarantees is integrity: that the payload hasn't been altered since the server issued it. If a server correctly verifies the signature on every incoming request, changing even one character of the payload — flipping a role from "user" to "admin", say — invalidates the signature and the token gets rejected. The entire security model rests on that verification step happening, server-side, every single time, which sounds obvious stated directly but is exactly the step that gets skipped often enough to be the most common real-world JWT vulnerability.

The alg: none attack and why algorithm confusion matters

The JWT specification technically allows "alg":"none" — a token with no signature at all, used legitimately only in narrow cases where a token's integrity is already guaranteed by its transport. Several early JWT libraries trusted the alg field from the token itself when deciding how to verify it, which meant an attacker could take any valid token, change the header to {"alg":"none"}, strip the signature, edit the payload freely, and have it accepted — because the library dutifully checked "none" against "none" and called that valid. A closely related attack targets libraries that support both symmetric (HMAC, using one shared secret) and asymmetric (RSA/ECDSA, using a public/private key pair) algorithms: an attacker takes a token meant to be verified with an RSA public key, switches the header to claim HMAC instead, and signs the forged token using that same public key as if it were an HMAC secret — if the server's verification code blindly uses whatever algorithm the token header claims, it ends up checking the signature with the public key as a shared secret, which the attacker also has, since public keys are, definitionally, public. Both attacks have the identical root cause: trusting the token to declare its own verification method instead of the server enforcing a specific, expected algorithm regardless of what the header says.

Expiry is a claim, not an enforcement mechanism

The exp claim is just a number sitting in the payload; nothing about the JWT format makes a server actually check it. A library's decode function and its verify function are frequently two different calls, and it's entirely possible to decode a token, read its claims, and never call the function that validates the signature and checks expiry — code that looks like it's handling authentication while silently accepting anything, including a token that expired months ago or was never signed correctly in the first place. This is also why JWTs are typically issued with short expiries (minutes to an hour) paired with a separate, longer-lived refresh token: unlike a server-side session, which can be revoked instantly by deleting a database row, a self-contained JWT that's already been issued remains valid to any server that correctly checks its signature until its exp timestamp passes, with no built-in way to revoke it early short of maintaining a denylist — which, if you're maintaining one anyway, raises the question of what the stateless part of "stateless authentication" was actually buying you.

Where to actually store one client-side

Once a browser-based app has a JWT, it has to put it somewhere for subsequent requests, and the two common choices trade off against two different attacks. Storing it in localStorage and attaching it manually as an Authorization: Bearer header is simple and immune to CSRF (cross-site request forgery), since a malicious site can't read another origin's localStorage or make the browser attach it automatically — but anything in localStorage is fully readable by any JavaScript running on the page, which means a single successful XSS (cross-site scripting) vulnerability anywhere on the site hands the attacker every stored token outright, no further exploitation needed.

Storing it in an httpOnly cookie instead closes that specific door — JavaScript cannot read an httpOnly cookie's value at all, so an XSS bug can't simply read the token out of storage — but cookies are attached to requests automatically by the browser, which reopens the door to CSRF: a malicious page can trigger a request to the real site and the browser will dutifully attach the cookie, authenticating a request the user never intended to make. The standard mitigation is pairing the cookie with the SameSite attribute (Strict or Lax, which stops the cookie being sent on most cross-site requests) and a separate CSRF token the attacker's page has no way to read. Neither storage choice is a free win; each closes one attack class by reopening exposure to the other, which is why "where do I put the JWT" is a genuine design decision with real consequences rather than a trivial implementation detail.

Reading one without guessing

Because the header and payload are just Base64URL-encoded JSON, decoding one requires no library and no secret — only the signature needs the key to verify. That makes a JWT genuinely useful to inspect directly when debugging an auth issue: checking what claims a token actually carries, what algorithm it declares, and whether its exp has already passed, all without needing server-side access or the signing secret at all. What decoding can never tell you is whether a given token would actually pass verification on the server that issued it — that check needs the secret or public key, and is the one step that has to happen on every request, not just the ones where something looks wrong.