What Is Inside a JWT — and What It Does Not Protect
Published 7/6/2026 · 16 min read · Developer tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 6 sources
A JSON Web Token is three base64url-encoded strings joined by dots: header, payload, signature. The signature proves the first two parts have not been altered by anyone without the key. It does not hide them. Take this real HS256 token — eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsInJvbGUiOiJlZGl0b3IiLCJpYXQiOjE3ODY1NzkyMDAsImV4cCI6MTc4NjU4MjgwMCwiaXNzIjoiaHR0cHM6Ly9hbGxpbi5leGFtcGxlIiwianRpIjoiYTFiMmMzIn0.bu5qB6d_D3--WUj4b77tnh-wdH6FhKhLcbh2nvNmyl4 — and decode the middle section with no key at all. Out comes {"sub":"1234567890","name":"Ada Lovelace","role":"editor","iat":1786579200,"exp":1786582800,"iss":"https://allin.example","jti":"a1b2c3"}. Base64url is an encoding, not a cipher. The first part names the algorithm, the third is a 43-character tag over the first two, and the whole token is 264 characters that ride on every request. Anything you put in a JWT payload — email addresses, internal identifiers, permission flags — is readable by the browser, by any proxy that logs the header, and by anyone who reads the token off a screen. Two consequences follow. Put nothing confidential in a payload; use JWE if you genuinely need the contents hidden, which is a different specification. And remember the signature cannot revoke: a stolen token stays valid until its exp passes, which is why short-lived access tokens are paired with a revocable refresh token.
A JWT is signed, not encrypted. Anyone holding the token can decode the payload and read every claim in it. Here is a real token, decoded without any key, plus the three attacks the signature is supposed to stop and the one problem it cannot solve.
Three parts, two dots
Every JWT has the same skeleton: header, dot, payload, dot, signature. The header is a tiny JSON object naming the algorithm — here {"alg":"HS256","typ":"JWT"}, which base64url-encodes to eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. The payload is another JSON object holding the claims. The signature is computed over the first two encoded parts joined by their dot, which is why you can never reorder or reformat them: the exact bytes are what was signed.
RFC 7519 reserves seven claim names, and knowing them saves a lot of reinvention. iss is the issuer, sub the subject, aud the intended audience, exp the expiry, nbf the not-before time, iat the issued-at time and jti a unique token identifier. The three time claims are NumericDate values — plain seconds since the Unix epoch, not milliseconds, which is a reliable source of off-by-1000 bugs in JavaScript. In the token above iat is 1786579200 and exp is 1786582800: a difference of 3,600, so a one-hour lifetime.
Size adds up faster than people expect. That payload is 137 bytes of JSON and becomes 183 characters once encoded, because base64 always costs four output characters per three input bytes — a 33% surcharge. The whole token is 264 characters, and it rides on every single request. Switch to RS256 with a 2048-bit key and the signature alone jumps from 32 raw bytes to 256, which is 342 base64url characters: the token roughly doubles. Stuffing a permissions array into the payload is how teams end up hitting proxy header limits.
Decode it yourself: the payload is in the clear
Copy the middle section of the token — the run of characters between the two dots — and base64url-decode it. No key, no library, no permission. What comes back is {"sub":"1234567890","name":"Ada Lovelace","role":"editor","iat":1786579200,"exp":1786582800,"iss":"https://allin.example","jti":"a1b2c3"}. Every claim, in plain text, including the role. If that had been an email address, an internal customer number, a subscription tier or a feature flag, it would be equally visible.
The confusion is understandable, because the token looks like ciphertext. It is not. Base64url exists to move arbitrary bytes through channels that only tolerate a restricted character set — URLs, headers, filenames. There is no key, so there is nothing to keep secret and nothing to break. The signature protects integrity, meaning that if anyone edits the payload the third part will no longer verify, so the server rejects the token. Integrity is not confidentiality, and a JWT delivers only the first.
The practical rule follows directly. Treat a JWT payload as a public bulletin board that happens to be tamper-evident. Put identifiers in it, not secrets. Put a user id rather than an email; put a role name rather than the reasoning behind it; put nothing you would not print on the outside of an envelope. And store it accordingly: an HttpOnly, Secure, SameSite cookie keeps it out of reach of page scripts, whereas localStorage hands it to any script that manages to run on your origin.
Why base64url and not base64
Standard base64 uses sixty-four characters ending in + and /, and pads the output with = to a multiple of four. All three of those are hostile to URLs and headers: + means a space in form-encoded data, / is a path separator, and = is a key-value separator in a query string. RFC 4648 therefore defines a URL-safe alphabet that swaps + for -, / for _, and drops the padding entirely. That is base64url, and it is what every part of a JWT uses.
The signature in the token above shows it in the open: bu5qB6d_D3--WUj4b77tnh-wdH6FhKhLcbh2nvNmyl4 contains both an underscore and three hyphens. Converted back to standard base64 the same 32 bytes read bu5qB6d/D3++WUj4b77tnh+wdH6FhKhLcbh2nvNmyl4=. If you ever decode a JWT part with a plain base64 function and get garbage, this is why — you have to substitute - back to + and _ back to / first, and re-add the padding if the decoder insists on it.
What the signature stops, and what defeats it
Change one character of the payload and the signature no longer matches, because HMAC-SHA-256 over the modified header.payload string produces something completely different. Re-signing the tampered payload requires the secret, which the client does not have. That much works. The historic failures are not attacks on the cryptography; they are attacks on the verifier, and both of the classic ones come from trusting the header.
The first is alg: none. RFC 7515 defines an unsecured JWS whose header reads {"alg":"none"} and whose signature part is simply empty — the token ends with a bare dot. Libraries that read alg from the header and dispatched accordingly would accept such a token and treat its claims as verified. An attacker rewrites the payload however they like, sets alg to none, drops the signature and walks in. The fix is not to reject the string none; it is to stop asking the token what algorithm to use. The verifier already knows which algorithm and which key it expects, and anything else is rejected before parsing goes any further.
The second is HS256 against RS256 confusion, and it is subtler. With RS256 the server holds a private key for signing and publishes the matching public key for verification. If a verifier takes the algorithm from the header, an attacker can set alg to HS256 and sign the token with the public key as if it were an HMAC secret. The verifier then dutifully runs HMAC using that same public key — which it has, since it is public — and the tag matches. That works: keying HMAC-SHA-256 with the PEM text of a public key produces a token a naive verifier accepts. Pinning the expected algorithm closes both attacks at once, which is why RFC 8725 makes it a headline recommendation.
A third mistake needs no cryptography at all: verifying the signature and then forgetting to check the claims. A structurally valid token whose exp passed last week is still structurally valid. So is one issued by a different tenant, or intended for a different audience. Always check exp against the current time with a small clock-skew allowance, check iss against the issuer you expect, and check aud against your own identifier — the signature says who wrote the claims, not whether they still apply to you.
The problem no signature can solve: revocation
The whole appeal of a JWT is that the server does not have to look anything up. The token carries its own claims and its own proof, so any node with the key can verify it in microseconds without touching a database. That is also its structural weakness, and the two cannot be separated: a server that consults no state cannot know that you fired the user five minutes ago. The token stays valid until exp arrives, and nothing in the specification provides a way to shorten that.
The standard answer is a two-token split. The access token is a JWT with a deliberately short exp — five to fifteen minutes is the usual range — and is checked statelessly on every request. The refresh token is long-lived, opaque, stored server-side, and exchanged for a new access token when the short one runs out. Revocation happens on the refresh token, which is stateful and therefore cancellable. The exposure window shrinks to the remaining lifetime of the access token, which is exactly what exp was chosen to bound.
If you need faster revocation than that, you have to reintroduce state, and you should do it deliberately. A deny list keyed on the jti claim lets you cancel individual tokens; the entries can be dropped as soon as the corresponding exp passes, so the list stays small. Rotating the signing key invalidates every token at once, which is the blunt instrument for a suspected key compromise. Both cost you a lookup, and at that point it is fair to ask whether an opaque session identifier in a cookie would have been simpler from the start.
JWE is the other specification, and it is not a JWT setting
When the claims genuinely must be hidden, the answer is JSON Web Encryption, defined in RFC 7516. A JWE is a different serialisation with five parts rather than three — protected header, encrypted key, initialisation vector, ciphertext, authentication tag — and it provides confidentiality and integrity together, because it uses authenticated encryption. You can nest the two, signing a JWT and then encrypting the result, which is what RFC 7519 calls a Nested JWT.
In practice, most teams do not need JWE and should not reach for it first. If the payload contains something you would rather nobody read, the usual right answer is to take it out of the payload. Replace the sensitive value with an opaque identifier the resource server can resolve, and the confidentiality problem disappears along with the extra key management, the extra library surface and the extra failure modes. Reach for JWE when a token has to cross a party that must forward it without reading it — that is the case it was designed for.
A checklist for the verifier
Pin the algorithm before parsing anything, and reject a token whose header disagrees. Verify the signature with a constant-time comparison. Then check exp, and nbf if present, against the current time with a skew tolerance of no more than a minute. Check iss against the exact issuer you trust and aud against your own identifier. Only after all of that should you read the application claims, and even then, treat role or scope as an assertion by the issuer rather than the last word — the resource server still owns its own authorisation decisions.
| Part | Contents | Size in this token | Readable without a key? | Covered by the signature? |
|---|---|---|---|---|
| Header | alg and typ — which algorithm signed it | 36 characters | Yes, fully | Yes — but the verifier must not trust alg blindly |
| Payload | The claims: sub, iss, exp, iat, jti and anything you add | 183 characters for 137 bytes of JSON | Yes — this is the point people miss | Yes — it cannot be edited without the key |
| Signature | HMAC-SHA-256 over header.payload, or an RSA/ECDSA signature | 43 characters for 32 raw bytes | Yes, but it is meaningless on its own | It is the signature |
| What is missing | Confidentiality, and any way to cancel the token early | Zero bytes are spent on either | Not applicable | Use JWE for the first, a refresh token or a deny list for the second |
Frequently asked questions
- Is a JWT encrypted?
- No. A standard JWT is signed, which is a different guarantee. The header and payload are base64url-encoded, an encoding with no key and no secret, so anyone holding the token can read them. Decoding the payload of the sample token in this article with no key returns {"sub":"1234567890","name":"Ada Lovelace","role":"editor","iat":1786579200,"exp":1786582800,"iss":"https://allin.example","jti":"a1b2c3"} — every claim in plain text. What the signature buys you is tamper evidence: change any character and the third part stops verifying, so the server rejects it. If you actually need the contents hidden, JSON Web Encryption (RFC 7516) is the specification for that, and it is a separate format with five parts rather than three, not a flag you set on a JWT. In most designs the better move is simply to keep confidential values out of the payload and carry an opaque identifier instead.
- Can someone modify a JWT payload without the secret?
- They can change the characters, but the result will not verify — provided your verifier is written correctly. The signature is computed over the exact header.payload string, so any edit produces a mismatch and the token is rejected. Two verifier bugs undo that protection. The first is trusting the alg field in the header: a token declaring {"alg":"none"} with an empty signature part was historically accepted by libraries that dispatched on the header, letting an attacker rewrite the payload at will. The second is HS256/RS256 confusion, where an attacker switches an asymmetric token to HMAC and signs it with the server's own public key, which the verifier then uses as the HMAC secret; this genuinely produces a matching tag. Both are closed by the same measure: decide the expected algorithm and key in your own code before parsing, and reject anything that disagrees. RFC 8725 gives this as a primary recommendation.
- How do I log a user out if a JWT cannot be revoked?
- Deleting the token on the client ends the session for that browser, and for many products that is genuinely enough. It is not enough when the token may have been copied, because a signed token remains valid until its exp regardless of what the client does. The standard structure is a short-lived access JWT — five to fifteen minutes — paired with a long-lived opaque refresh token stored server-side. Logging out deletes the refresh token, so the session cannot be renewed and dies within the access token's remaining lifetime. If you need faster than that, add state deliberately: a deny list keyed on the jti claim can cancel individual tokens, and entries can be purged as soon as the matching exp passes, so it never grows without bound. Rotating the signing key kills every outstanding token at once and is the right response to a suspected key compromise. Each of these reintroduces a lookup, which is the cost of the revocation you wanted.
- Where should I store a JWT in a browser?
- In a cookie marked HttpOnly, Secure and SameSite, in almost every case. HttpOnly puts the token out of reach of JavaScript, so a cross-site scripting flaw anywhere on your origin cannot read it and send it away; Secure keeps it off plaintext connections; SameSite blocks the cross-site request forgery that cookies would otherwise expose you to. localStorage is the common alternative and the weaker one, because any script running on your page — including one pulled in by a compromised dependency — can read every key in it. The argument for localStorage is usually that the token must be attached to cross-origin API calls by hand, which cookies make awkward; that is a real constraint, but it is worth solving with a same-origin proxy rather than by making the token script-readable. Whichever you pick, keep the payload free of anything sensitive, because storage decides who can steal the token, not who can read it once stolen.
- How long should a JWT last?
- Short enough that its uncancellable lifetime is an acceptable exposure. Since a signed token stays valid until exp no matter what happens on your side, exp is the whole width of the window in which a stolen token still works. Five to fifteen minutes is the common range for access tokens, and the sample token in this article uses one hour — iat 1786579200, exp 1786582800, a difference of exactly 3,600 seconds. Anything measured in days is effectively a permanent credential with a signature attached. The refresh token carries the long lifetime instead, and it can be long precisely because it is opaque, stored server-side and revocable. Two implementation details matter. NumericDate values are seconds, not milliseconds, so comparing exp against Date.now() in JavaScript without dividing by 1000 is the classic bug. And allow a small clock-skew tolerance, on the order of thirty to sixty seconds, or tokens will occasionally be rejected by a server whose clock runs slightly ahead of the issuer's.
- Should I use a JWT or a plain session cookie?
- For a single application with one database, a plain session identifier in a cookie is usually the simpler and stronger choice. It is a random opaque string, it reveals nothing, and logging out is a row deletion that takes effect instantly. The JWT earns its complexity when verification has to happen somewhere the session store is not — several services behind a gateway, a third-party API accepting your identity assertions, an edge function that cannot afford a database round trip. That is a real architectural advantage and it is the reason JWTs exist. Do not adopt one for a monolith that already has a session table, and be honest about what you are trading: statelessness buys you fast distributed verification and costs you instant revocation, and you cannot keep both. Teams that add a deny-list lookup to every request have paid the JWT's complexity and given back its only structural benefit.
Articles you may find interesting
All guides →Related tools
Sources
Spotted a mistake in this article?