Skip to content
Allin

What Is Inside a JWT — and What It Does Not Protect

Published 7/6/2026 · 16 min read · Developer tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 6 sources

View profile
In short

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.

The three parts of a real 264-character HS256 token: what each holds, who can read it, and what the signature covers
PartContentsSize in this tokenReadable without a key?Covered by the signature?
Headeralg and typ — which algorithm signed it36 charactersYes, fullyYes — but the verifier must not trust alg blindly
PayloadThe claims: sub, iss, exp, iat, jti and anything you add183 characters for 137 bytes of JSONYes — this is the point people missYes — it cannot be edited without the key
SignatureHMAC-SHA-256 over header.payload, or an RSA/ECDSA signature43 characters for 32 raw bytesYes, but it is meaningless on its ownIt is the signature
What is missingConfidentiality, and any way to cancel the token earlyZero bytes are spent on eitherNot applicableUse JWE for the first, a refresh token or a deny list for the second
JWT GeneratorBuild and HMAC-sign a JSON Web Token in your browser — HS256, HS384 or HS512.Try the tool

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
ExplainerWhat Is a JWT (JSON Web Token)?A JWT is a compact, signed token used to carry identity between services. Here's its three parts, how it's used for auth, and its security limits.ExplainerPassword Entropy: What a Strength Meter Cannot KnowEntropy measures the process that produced a password, not the characters in it. H = L x log2(R) is only true when every character was chosen at random — which is exactly why a meter scoring a human-invented password on its character classes is measuring the wrong thing.ComparisonMD5, SHA-1, SHA-256: Which Hash, and For WhatMD5 is broken and MD5 is fine, depending on which of three security properties you needed. Here is what collision resistance, second-preimage resistance and preimage resistance actually mean, which algorithm still has which, and why none of them belongs near a password.ExplainerWhat Is a Hash Function? (MD5, SHA-256)A hash function turns any input into a fixed-size fingerprint. Here's what it does, its key properties, common uses, and which algorithms are safe.ExplainerWhat Is a UUID (and When to Use It)?A UUID is a 128-bit identifier that's unique without any central authority. Here's what it looks like, why it's useful, the versions, and when to use one.GuideBuilding a URL With Parameters That Survives a Copy-PasteThree encodings, one visible difference: %20 or +. The builder's form mode matches URLSearchParams byte for byte on seventeen values — but give it a base URL with a fragment and every parameter lands inside the hash, where no server sees it.

Related tools

Sources

Spotted a mistake in this article?