Paste a JWT into a decoder and the thing that surprises people every time is how little is actually hidden. The payload's just sitting there in plain JSON, readable by anyone who has the token, no key required. That's not a bug. It's the whole design, and once you get that, the rest of this makes a lot more sense.
A JWT is three base64url strings joined by dots. The server builds one, signs it, hands it to your browser. Your browser sends it back on every request after that, and the server checks the signature instead of hitting a database. That's genuinely the entire mechanism — signed, not encrypted, which is the one distinction worth burning into memory before anything else on this page.
Here's a real token, so you're not just reading theory:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6InRleHRhdmlhLWRlbW8iLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwic2NvcGUiOiJyZWFkOnByb2ZpbGUifQ.TqK-FhgZPcTnStcG9guKUhv1IJroYHQ5B61l6DejqKE
Real HS256 token, signed with the secret textavia-demo-secret. Safe to poke at, since it expired back on January 1st, 2025, and the secret's printed right there in this sentence — worthless as a credential, genuinely useful as something to take apart.
Split on the dots and you get three chunks:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6... ← payload
TqK-FhgZPcTnStcG9guKUhv1IJroYHQ5B61l6DejqKE ← signature
Every JWT you'll ever see starts with eyJ. Not a coincidence — that's what {" turns into once it's base64 encoded, and both the header and the payload happen to start with a JSON object's opening brace.
The header, decoded
First segment, decoded, looks like this:
{
"alg": "HS256",
"typ": "JWT"
}
Just says how the thing was signed, really.
algis the signing algorithm. HS256 means HMAC with SHA-256.typ, almost always justJWT, not doing much work.- And
kidshows up when whoever issued the token rotates between a few different keys — it's a hint telling the server which one to reach for.
Worth remembering: whoever creates the token writes this header. That detail matters a lot later, when we get to how these things get attacked.
The payload, decoded
Second segment gives you the actual claims:
{
"sub": "1234567890",
"name": "Ada Lovelace",
"iss": "https://auth.example.com",
"aud": "textavia-demo",
"iat": 1735689600,
"exp": 1735693200,
"scope": "read:profile"
}
Seven claims sitting in there. RFC 7519 registers a standard handful of them:
| Claim | Name | Meaning |
|---|---|---|
iss | Issuer | Who made the token |
sub | Subject | Who it's about — usually a user ID |
aud | Audience | Which service should accept it |
exp | Expiration | Reject after this |
nbf | Not before | Reject before this |
iat | Issued at | When it was made |
jti | JWT ID | Unique ID, useful for one-time tokens and denylists |
Anything past that — name, scope, role, whatever your app cares about — falls outside the registered set. And there's actually a split worth knowing about even here. If you're defining a claim you want other systems to recognize too, the spec calls that a public claim, and the convention is to namespace it as a URI (something like https://yourcompany.com/roles) so it can't accidentally collide with someone else's claim of the same name. Most of the time you don't need that. A private claim is just an ordinary name like scope or role, agreed on between whoever's issuing the token and whoever's reading it, with no attempt at global uniqueness — which is fine, since nobody outside that agreement needs to understand it.
The timestamp bug everyone hits eventually
exp, iat, and nbf are NumericDate values. Seconds since the Unix epoch. Not milliseconds. And this trips people constantly, because Date.now() in JavaScript hands you back milliseconds without asking.
exp: 1735693200 ← correct, ~10 digits, lands on 2025-01-01T01:00:00Z
exp: 1735693200000 ← wrong, 13 digits, this is now the year 56000-something
A 13-digit exp is functionally a token that never expires, since you've just pushed the expiration date fifty-four thousand years out. If a token in your system never seems to expire and you can't figure out why — count the digits first. Genuinely, before anything else.
What doesn't belong in there
The payload's base64url. That's encoding. It is not, under any definition, encryption. Every hop the token makes, the browser, any proxy in between, your logging pipeline, some browser extension you forgot was installed, can read it in plain text.
So no passwords. No API keys, no card numbers, nothing you wouldn't be fine publishing on a billboard. A user ID's fine. An email address is a judgment call depending on your threat model. A national ID number is not fine, ever.
Size matters here too, and it's the kind of thing that bites you months later. This example token is 287 characters and it rides along in a header on every single request your app makes. Cram a big permissions array in there and you can genuinely blow past the header size limits that servers and CDNs enforce — usually somewhere around 8 KB, which sounds like a lot until you've got fifteen roles and a few nested objects in there.
The signature, which is where the actual security lives
Third segment. This is the part doing all the work.
It's computed over the first two segments, exactly as they appear, dot and all:
signature = HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret
)
Two things fall out of that. Change one character anywhere in the header or payload and the signature stops matching, immediately. And you cannot produce a valid signature at all without the secret — not "difficult," just mathematically not possible without it.
That's really the entire security model in one sentence. The payload's public. The signature is the only thing standing between "readable" and "trustworthy."
Symmetric and asymmetric, and why the difference actually matters
| Algorithm | Type | Signs with | Verifies with | Use it when |
|---|---|---|---|---|
HS256 | Symmetric HMAC | Shared secret | Same secret | One service issues and checks its own tokens |
RS256 | Asymmetric RSA | Private key | Public key | Lots of services verify, only one issues |
ES256 | Asymmetric ECDSA | Private key | Public key | Same shape as RS256, smaller signatures |
none | — | Nothing | Nothing | Never. Genuinely never |
With HS256, anyone who can verify a token can also forge one, because it's literally the same secret doing both jobs. Hand that secret to five microservices so they can each check tokens, and congratulations, all five can now also mint valid tokens for anyone. RS256 fixes that specific problem — the auth service keeps the private key locked down, everyone else gets a public key that can check signatures but can't produce new ones.
What happens on an actual request
- You log in with a username and password
- The auth server checks them, builds a payload, signs it
- It hands the JWT back
- Your browser stores it somewhere
- Every request after that sends
Authorization: Bearer <token> - The server recomputes the signature over the header and payload itself
- If the signatures match and
exphasn't passed, the request goes through
Step six's the whole reason people reach for JWTs in the first place. No session store to query, no database round-trip, just a hash recomputed on the spot. Fast, and it works fine spread across a hundred stateless servers that share nothing.
It's also, not coincidentally, the source of basically every problem covered below.
Okay, but why bother with any of this
Fair question, since sessions have worked fine for decades. A few reasons JWTs caught on anyway.
Cross-domain is the big one. Cookies are tied to a domain by default, which gets awkward fast once you've got an app on one subdomain talking to an API on another, or a proper single sign-on setup spanning several completely different services. A JWT doesn't care. It's just a string, so it travels in a header and works wherever you hand it, which is most of why SSO implementations lean on this format so heavily.
There's also the fact that the token already contains what you need, so a service checking permissions doesn't have to go ask a user database "hey, is this person actually an editor." It's right there in the payload, verified, ready to use. Cuts down on a lot of chatty internal network calls, especially at any real scale.
Mobile apps and public APIs like this shape too — small, URL-safe, no cookie jar to manage, plays nicely with whatever HTTP client a mobile SDK happens to be using that year.
One thing worth being skeptical of, though: you'll sometimes see JWTs pitched as more secure than the alternative, full stop. That's overselling it a bit. What signing actually buys you is integrity — proof nobody tampered with the claims in transit. It does nothing for confidentiality, which is the part people keep tripping over throughout this whole page.
Decoding is not the same thing as verifying
This is the one distinction that gets blurred constantly, and it's the one that actually matters most.
| Decoding | Verifying | |
|---|---|---|
| Needs the key? | Nope | Yes |
| Who can do it | Literally anyone with the token | Only whoever holds the key |
| Tells you | What the token claims | Whether those claims are real |
| Good for | Debugging, reading claims | Deciding whether to let a request through |
A decoder splits the token, base64url-decodes the first two segments, shows you JSON. And a decent one explicitly tells you the signature wasn't checked — because that distinction is worth repeating even when it feels obvious.
Decoding answers "why is this request getting rejected." It'll show you an exp sitting in the past, an aud pointed at the wrong service entirely, an iss that's clearly from staging. What it cannot tell you, ever, is whether the token's genuine. Don't make an authorization call off a decoded payload. Verify the signature, server-side, with a real library, every time.
How this actually gets broken in production
Nearly every real JWT vulnerability traces back to one root cause: the server trusts the token to tell it how the token should be checked. Which, said out loud, sounds obviously bad. And yet.
alg: none
The spec includes an algorithm literally called none — meaning unsigned. It exists for edge cases where the token's protected some other way already. It's also, in practice, a gift to anyone poking at your API.
Take that example token from earlier. Change scope to admin, flip alg to none, delete the signature entirely:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6InRleHRhdmlhLWRlbW8iLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwic2NvcGUiOiJhZG1pbiJ9.
Notice the trailing dot with nothing after it. A library that reads alg straight from the header and just does whatever it says will see none, skip verification entirely, and hand your app a payload claiming admin scope. Every current library blocks this by default now, thankfully. Plenty of code written five or six years ago does not, and it's still running somewhere.
Algorithm confusion, the subtler one
This one's actually hit real production systems, not just conference talks. A service verifies RS256 tokens using a public key. Which is, by definition, public — that's the entire point of it.
So an attacker grabs that public key, uses it as an HMAC secret instead, signs a forged token with alg: HS256, sends it over. If the server just calls verify(token, key) and lets the token itself pick the algorithm, it ends up treating the public key as an HMAC secret, and the signature checks out clean.
Fix for both of these, and it's the same fix: pin the algorithm yourself, server-side, and never let the token decide.
// Vulnerable — the token gets to choose
jwt.verify(token, key);
// Correct — you choose
jwt.verify(token, key, { algorithms: ['RS256'] });
The smaller mistakes that add up
- Not checking
expat all. Some libraries do this automatically. Some don't. Go check which one you're actually using. - Skipping
aud. A token that's valid for some other service is still, technically, a validly signed token — without an audience check, your payments API might happily accept a token that was only ever meant for analytics. - Skipping
isstoo. - Storing the token in
localStorage. Any script running on the page can read it, so one XSS bug anywhere and your tokens just walk out the door. AnhttpOnlycookie can't be read by JavaScript at all, though you'll needSameSitehandling to keep CSRF in check. - Shipping any of this over plain HTTP. Sounds obvious written down, and yet — a token sitting in an
Authorizationheader is just as interceptable as a password would be over an unencrypted connection. HTTPS everywhere, no exceptions, or none of the signing work above matters at all. - Treating the signing secret casually. It's usually sitting in an environment variable somewhere, and that's fine, but "fine" assumes it's not also checked into the repo, printed in a log line, or pasted into a Slack thread during a debugging session (yes, this happens, more than anyone likes to admit).
- Long expiry windows, which is its own section, coming up.
And a smaller one worth naming on its own: replay attacks. A stolen token, intercepted somehow, is just as valid to the server as the real one — it doesn't know the difference between "the legitimate owner" and "someone who copied this out of a proxy log." Short expiries limit the damage window. So does binding a token to something like an IP or device fingerprint, though that adds its own complexity and false-positive headaches.
What JWTs are genuinely bad at
Revocation's the big one. Log a user out, ban an account, yank a permission — none of it invalidates a token that's already out in the wild. It just keeps working right up until exp hits, and you can't reach out and grab it back. Every workaround for this gives back some of the statelessness that made JWTs appealing in the first place, which is a little ironic if you sit with it:
- Short access tokens, five to fifteen minutes, paired with a refresh token that does get checked against a database
- A denylist of revoked
jtivalues, meaning a lookup on every single request anyway - A per-user cutoff timestamp — "anything issued before this moment is dead" — which also means a lookup
Sessions in general are the other weak spot. One server, one database? A session cookie's simpler, revokes instantly, and takes up less space on the wire. JWTs start earning their complexity once you've got several services that all need to verify identity independently, without sharing a session store between them.
And storing a lot of data in one, obviously — every claim rides along on every request, forever, whether you need it for that particular call or not.
When something's broken, read it in this order
Paste the token into a decoder and work down this list before you do anything else:
- Three parts? Count the dots — two dots means three segments. A truncated token is honestly the single most common cause of "invalid token" errors, so check whether it got clipped somewhere by a length limit.
- Is
expactually in the past? Convert it properly. Ten digits, not thirteen — see above. - Does
audname the service you're actually calling right now? - Does
issmatch the environment you meant to hit? Staging tokens landing on production is a classic here. - What does
algsay? If it'snone, stop, something's very wrong. - Did the
Bearerprefix get copied in by accident? The token itself starts ateyJ, nowhere else.
The specific errors you'll run into
"Invalid JWT format. A JWT must have exactly 3 parts separated by dots" means the segment count's off. Usually a truncated token, a Bearer prefix that got copy-pasted along with it, or a stray line break in the middle somewhere.
"Invalid Base64 encoding in JWT" means a segment isn't valid base64url. Often a line wrapped weirdly in a terminal, or + and / characters getting mangled by URL encoding somewhere along the way — JWTs use base64url specifically, where + becomes -, / becomes _, and padding gets stripped entirely.
"Malformed JSON" means the segment decoded fine but what came out isn't valid JSON. Usually a partial or corrupted copy-paste.
Quick answers
Is a JWT encrypted? No. Signed and base64url-encoded, which is very different. Anyone holding the token reads the payload plainly. If you actually need the contents hidden, that's JWE, a separate spec entirely.
Can I trust a JWT just because it decodes cleanly? No, and this is worth sitting with — decoding proves the token is well-formed. Only checking the signature against the key proves it's genuine.
Where should a browser keep one? An httpOnly, Secure, SameSite cookie beats localStorage most of the time, since scripts can't touch it. Neither option's free of trade-offs though — cookies bring their own CSRF homework.
How long should one last? Access tokens, five to fifteen minutes. Refresh tokens, days or weeks, kept server-side so they're actually revocable. Long-lived access tokens are, in my experience, the single most common mistake teams make here.
Can I just edit a JWT's payload myself? Only if you've got the signing key. Edit it without re-signing and the signature breaks immediately — any server that's configured correctly rejects it on the spot.
Is pasting a real token into an online decoder safe? Depends entirely on whether decoding happens in your browser or gets sent to a server somewhere. A client-side one transmits nothing. Even so, treat a production token roughly like a password — use an expired or test one when you're just poking around, and skip shared machines.
Why does every JWT start with eyJ? Because the header's a JSON object, so it starts with {", and {" just happens to encode to eyJ in base64. That's it. That's the whole mystery.
