🔒Security & Privacy

JWTs in 2026: Structure, Signing, and the Security Mistakes Everyone Makes

What the three dot-separated parts of a JWT actually contain, why anyone can read your token but not forge it, the alg:none and HS256/RS256 key-confusion attacks explained from scratch, why the localStorage-vs-cookie debate has no clean winner, and the exact claims you must validate before trusting a token.

Published July 15, 2026
16 min read
By Toolsana Team

JSON Web Tokens are everywhere — they're how most APIs and single-page apps know who you are — and they're surrounded by more confident misinformation than almost any other web primitive. The two claims you'll hear most often ("JWTs are encrypted" and "store them in localStorage") are, respectively, false and usually wrong. Meanwhile the actual security failures are specific and repeatable: a header field that lets an attacker turn off the signature, a key mix-up that lets a public key forge tokens, a missing aud check that lets a token cross service boundaries it was never meant to.

This guide builds the mental model from the ground up — what a token is, what each part contains, how signing makes it trustworthy — and then walks through the mistakes everyone makes, each one explained from scratch rather than name-dropped.

What a JWT actually is

A JWT is a way to carry claims — statements like "the user is 1234", "they're an admin", "this token is good until 3pm" — in a single string that the receiver can check for tampering. That's the whole idea: not secrecy, but verifiable integrity. The server can hand you a token, and later trust the token you hand back, because any change to it would break a cryptographic signature only the server can produce.

Here's a real one (line-broken for readability — a real token has no spaces):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three chunks, separated by dots: header . payload . signature. Each of the first two is just a JSON object that's been base64url-encoded (more on that in a second). Decode this token — paste it into the JWT decoder — and it reads:

// header
{ "alg": "HS256", "typ": "JWT" }

// payload
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }

That's it. The token is self-contained: everything the server needs to know about you is inside it, which is what lets the server avoid a database lookup on every request. Let's take the three parts one at a time.

Part 1: the header — how to verify this token

The header is a small JSON object naming the signing algorithm and the token type:

{ "alg": "HS256", "typ": "JWT" }

alg is the important field — it says how the signature was produced (here, HMAC-SHA256). Hold onto this: the fact that the token itself announces its own algorithm is the seed of two real attacks later. Base64url-encode this JSON and you get the first chunk, eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.

Part 2: the payload — the claims

The payload holds the claims — the actual data. Some names are registered claims with standard meanings (defined in RFC 7519), and you should use them rather than inventing your own:

ClaimMeaning
issIssuer — who created the token (your auth server)
subSubject — who the token is about (usually the user ID)
audAudience — which service the token is for
expExpiration — a Unix timestamp after which the token is invalid
nbfNot before — a Unix timestamp before which it isn't valid yet
iatIssued at — when the token was created
jtiJWT ID — a unique identifier for the token

Beyond these you can add your own ("role": "admin", "tenant": "acme"). Note that exp, nbf, and iat are Unix timestamps — plain integers counting seconds since 1970 — so a value like 1516239022 isn't human-readable until you convert it; the Unix timestamp converter turns it into a date.

The critical thing about the payload: it is not secret. Which brings us to the misconception that causes the most damage.

The #1 misconception: a JWT is signed, not encrypted

Anyone who has your token can read everything in it. The payload is only base64url-encoded, and encoding is not encryption:

  • Encoding (base64url) is a reversible reformatting with no key — it just makes binary data safe to put in a URL. Anyone can decode it. It hides nothing.
  • Encryption requires a key to reverse. Without the key, the contents are unreadable.

A standard JWT uses encoding for the payload and a signature for integrity. There is no encryption anywhere. You can prove this to yourself in ten seconds: take the middle chunk of any token and run it through a base64 decoder — the JSON claims pop right out, no key required. (This is exactly why "Base64 is not encryption" is a security maxim, and why you should never treat a base64 string as hidden.)

So the rule is blunt: never put anything secret in a JWT payload. No passwords, no full personal records, no internal secrets, no API keys. Put an opaque user ID and the minimum claims needed for authorization, and look up anything sensitive server-side. If you truly need the contents concealed in transit, that's a different standard — JWE (JSON Web Encryption) — but 95% of the time the right move is simply to not put secrets in the token.

What the signature does protect is integrity: if an attacker changes "role": "user" to "role": "admin", the signature no longer matches and verification fails. They can read the claims; they can't change them undetected. That's the guarantee — and only that.

Part 3: the signature — where trust comes from

The signature is what makes the token trustworthy. It's computed over the encoded header and payload together, using a secret or key. Conceptually, for HS256:

signature = HMAC-SHA256(
  base64url(header) + "." + base64url(payload),
  secret
)

Two properties fall out of this:

  1. Tamper-evidence. The signature covers the header and payload. Change either one and the recomputed signature won't match the one attached — verification fails. This is why editing a claim to escalate privileges doesn't work against a correct implementation.
  2. Unforgeability. You can only produce a valid signature if you hold the secret (HS256) or private key (RS256). A server that issued a token can verify it later because it — and only it — could have signed it.

To verify, the server recomputes the signature over the received header and payload and checks it matches the third chunk. Match plus a valid exp and the token is trusted. You can experiment with the whole cycle — build a token with the JWT encoder, and compute or check a raw HMAC signature with the HMAC generator & verifier — to see exactly how changing one character of the payload invalidates the signature.

HS256 vs RS256: one key or two?

The signing algorithm decides how many keys exist, and that choice drives your whole architecture.

HS256 — symmetric, one shared secret. The same secret both signs and verifies. It's the simplest option and correct when the same service issues and checks tokens:

# both signing and verifying use this one value
JWT_SECRET = "a-long-random-secret-at-least-32-bytes"

The catch: anyone who can verify can also forge. The secret must stay on the server and never ship to a browser or a third party. Make it long and random — 32+ bytes — not a dictionary word; a password generator set to a long random string works for generating one.

RS256 — asymmetric, a key pair. A private key signs; a separate public key verifies. You can distribute the public key to any number of services and they can validate tokens without being able to mint them:

private key  →  signs tokens   (kept only on the auth server)
public  key  →  verifies tokens (safe to hand to every other service)

Use RS256 (or its elliptic-curve cousin ES256) when a central auth server issues tokens that many independent services must verify — the defining case for microservices and third-party integrations. Use HS256 when one service does both. The wrong move is letting the verifier accept either interchangeably, which leads directly to the next attack.

When should you actually use a JWT? (and how big should it be?)

Before the mistakes, a question that prevents most of them: do you even need a JWT? The honest comparison is JWT versus the older, simpler server session.

A server session keeps the state on the server. The browser holds a cookie containing only a random ID — session=8f3a... — and on every request the server looks that ID up in a database or cache to find out who you are:

Cookie:  session=8f3a1c9e     →  server looks up 8f3a1c9e  →  "that's user 1234"

A JWT puts the state in the token, so there's nothing to look up — the server just verifies the signature and reads the claims:

Authorization: Bearer eyJhbGc...   →  verify signature  →  claims say "user 1234, admin"

The tradeoff is the whole story:

Server sessionJWT
State liveson the serverin the token
Every requestneeds a lookupneeds only signature verification
Revoking accesstrivial — delete the recordhard — can't un-issue a self-contained token
Scaling across serversneeds shared storagestateless, works anywhere
Across separate servicesawkwardnatural — that's its strength

The practical guidance: for a single app, a server session is usually simpler and safer — instant revocation is a real security advantage, and one database lookup is cheap. JWTs earn their extra complexity when you have multiple services or a central auth server that issues tokens others must verify without a shared session store. Reaching for a JWT in a plain single-server app is a common case of importing distributed-systems machinery you don't need.

And keep tokens small. Because a JWT rides in an HTTP header (or a cookie) on every single request, its size is bandwidth you pay repeatedly. Headers have practical limits (many servers cap total header size around 8 KB; cookies are capped at ~4 KB each), and it's genuinely possible to build a token so stuffed with claims and roles that it overflows them and requests start failing. Put an ID and the few claims you need for authorization — not the user's entire profile. If you're tempted to pack a lot in, that's usually the signal to look the data up server-side instead.

The mistakes everyone makes

The JWT structure is sound. The failures are almost always in how tokens get verified and stored. Here are the ones that recur, each explained from the beginning.

Mistake 1: trusting the token's own alg — the alg:none attack

Remember that the header announces its own algorithm. A naive verifier reads alg from the header and does whatever it says. The attack: change the header to {"alg":"none"}, delete the signature (leave the final dot), and rewrite the payload freely.

# attacker-crafted token — note the empty third segment
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIn0.

A vulnerable server sees alg: none, concludes "no signature to check," and accepts a token that claims "role":"admin". The none algorithm exists in the spec for tokens whose integrity is guaranteed elsewhere — it was never meant to be honored on an incoming credential.

The fix — and it's absolute: never let the token choose the algorithm. Your verification call must pin the accepted algorithm(s):

// Node, jsonwebtoken
jwt.verify(token, secret, { algorithms: ['HS256'] });

Anything not on that list — including none — is rejected before the signature step matters. Modern libraries refuse none unless you opt in, but the bug still ships in custom verification code.

Mistake 2: HS256/RS256 key confusion

This one is subtle and depends on Mistake 1's habit of trusting the header. Suppose your system uses RS256: the public key verifies tokens and is, by design, public. Now suppose the verifier doesn't pin the algorithm and instead trusts the header's alg. An attacker:

  1. Takes your public key (it's public).
  2. Crafts a token with the header changed to {"alg":"HS256"}.
  3. Signs it using HMAC with your public key as the HMAC secret.

If the verifier reads alg:HS256 from the header and runs HMAC verification, it uses the public key as the HMAC secret — the exact value the attacker just used to sign. The signature matches. The attacker has forged a valid token using only public information.

The root cause is the same as alg:none: the verifier let the token dictate the algorithm. Pinning the algorithm to exactly what you expect (['RS256']) closes this too — the header saying HS256 is simply rejected. This is why "always specify the algorithm on verification" is the single highest-value JWT rule.

Mistake 3: putting secrets in the payload

Covered above, but it belongs on the mistakes list because it's so common: the payload is readable by anyone. No passwords, no PII beyond an ID, no secrets. Assume every claim is visible on a billboard.

Mistake 4: storing tokens where XSS can reach them

A JWT is a bearer token — whoever holds it is treated as the user, no questions asked. So where you keep it in the browser matters enormously, and there's no clean answer:

  • localStorage is easy but readable by any JavaScript running on the page. One cross-site scripting (XSS) bug — a malicious script injected into your site — and the attacker reads the token straight out of storage and impersonates the user. This is the pattern to avoid, especially for long-lived tokens.
  • httpOnly cookies are invisible to JavaScript, so XSS can't read them. But cookies are sent automatically by the browser, which opens CSRF (cross-site request forgery) — a malicious site triggering authenticated requests. You close that with SameSite=Strict or Lax and, for sensitive actions, anti-CSRF tokens.

The 2026 consensus: prefer an httpOnly, Secure, SameSite cookie, keep access tokens short-lived, and recognize that XSS is the real enemy — if an attacker can run JavaScript on your page, no storage choice fully saves you. Content Security Policy and disciplined output escaping matter more than the storage debate.

Mistake 5: no expiry strategy, and no way to revoke

Because a JWT is self-contained, you can't easily un-issue one — there's no server record to delete. If a token is stolen and it's valid for 30 days, the attacker has 30 days. The standard answer is the access + refresh token pattern:

  • Access token — a JWT, short-lived (minutes to ~1 hour), sent on every request. If stolen, it's dangerous only briefly.
  • Refresh token — long-lived (days to weeks), stored carefully, sent only to a token-refresh endpoint to obtain new access tokens. Because it's used rarely and tracked server-side, you can revoke it — deleting it is how you log someone out.

Rotate refresh tokens on each use, and if an old one is presented again, treat it as theft and invalidate the whole chain. The exp claim (that Unix timestamp) drives the access-token lifetime; verify it on every request and allow only a small clock-skew tolerance.

Mistake 6: verifying the signature but not the claims

A passing signature proves the token wasn't altered. It does not prove the token is meant for you, right now. You must also check:

  • exp — is it expired? Reject if so (with ~30–60s skew tolerance).
  • nbf — is it not-yet-valid?
  • iss — did it come from the issuer you trust?
  • aud — was it minted for your service? Skipping this is how a token issued for Service A, which shares a signing key with Service B, gets replayed against B. aud is the boundary.

Pass the expected issuer and audience to your verification library and it checks these for you. Signature + exp is the floor; iss + aud are what stop cross-service replay.

Debugging JWTs: reading the three common failures

When token auth breaks, the error is usually one of three, and each points somewhere specific:

  • invalid signature — the signature didn't match. Either the secret/key on the verifier differs from the signer's (the usual cause — check for a trailing newline or a wrong environment variable), or the token was tampered with, or you're verifying with the wrong algorithm. Decode the header with the JWT decoder and confirm alg matches what your verifier expects.
  • jwt expired — the signature was fine but exp is in the past. If tokens seem to expire "immediately," suspect clock skew: the signer's and verifier's clocks disagree. Read the exp value as a date with the timestamp converter and compare it to the server's actual time.
  • jwt malformed — the string isn't a valid JWT: wrong number of dot-separated parts, or a chunk that isn't valid base64url. Often a truncated token, a doubled Bearer prefix left in the header, or whitespace. Count the dots — there must be exactly two.

The golden rule while debugging: decoding is not verifying. A decoder happily shows you the contents of a completely forged token — it does no cryptographic check. Reading a token tells you what it claims; only signature verification tells you whether to believe it.

The gotcha checklist

The mistakes above, compressed for future reference:

  1. Assuming the payload is private — it's base64, readable by anyone. No secrets in tokens.
  2. Trusting the header's alg — always pin algorithms: ['HS256'] (or your exact choice). Defeats alg:none.
  3. Interchangeable HS/RS verification — the key-confusion forgery. Same fix: pin the algorithm.
  4. Long-lived tokens in localStorage — XSS reads them. Prefer httpOnly+Secure+SameSite cookies; keep access tokens short.
  5. No refresh/revocation strategy — short access token + revocable refresh token; rotate and detect reuse.
  6. Verifying signature but not exp/iss/aud — a valid signature isn't authorization; check the claims.
  7. Secret in source control — load HS secrets / RS private keys from secret storage, never the repo.
  8. Weak HMAC secret — 32+ random bytes, not a word. A guessable secret means anyone can mint tokens.
  9. Confusing decode with verify — a decoder shows forged tokens as readily as real ones.

The short version

A JWT is three base64url parts — header, payload, signature — carrying claims that anyone can read but only the key-holder can sign. It gives you stateless auth: the server trusts a token it can verify instead of a session it has to store. Everything that goes wrong flows from two facts people forget — the payload is not secret (so keep secrets out of it), and the token must not be allowed to choose its own verification algorithm (so pin it, defeating both alg:none and key confusion). Keep access tokens short and back them with revocable refresh tokens, validate exp/iss/aud and not just the signature, and remember that decoding a token — with the JWT decoder — tells you what it claims, never whether to believe it. For the related question of how to store the credentials a JWT represents, the password hashing guide covers the server side.

Frequently Asked Questions

What is a JWT and what is it actually for?

A JWT (JSON Web Token, pronounced 'jot') is a compact, self-contained way to carry a set of claims — facts like 'this is user 1234, an admin, and this token expires at 3pm' — in a form the receiver can verify wasn't tampered with. It's three base64url-encoded parts joined by dots: header.payload.signature. Its main job is stateless authentication: after you log in, the server hands you a signed JWT, and you send it on every subsequent request so the server knows who you are without looking anything up in a database. The signature is the whole point — anyone can read a JWT, but only the holder of the secret key can produce a valid one, so a server can trust a token it issued without storing session state.

Is a JWT encrypted? Can anyone read what's inside?

By default, no — a standard JWT is signed, not encrypted, and anyone who intercepts it can read every claim inside. The payload is only base64url-encoded, which is encoding (reversible by anyone), not encryption (reversible only with a key). Paste any token into a decoder and the header and payload appear as plain JSON instantly. This is the single most dangerous JWT misconception: developers put passwords, full personal records, or internal flags in the payload assuming it's hidden. It is not. The signature protects integrity (nobody can change the claims undetected), never confidentiality. If you genuinely need the contents hidden, you need JWE (encrypted JWTs), which is a separate, less common standard — but the usual right answer is to simply not put secrets in a token.

What is the difference between a JWT and a session cookie?

A traditional session stores state on the server: the cookie holds only a random session ID, and every request triggers a database or cache lookup to find who that ID belongs to. A JWT stores the state in the token itself — the claims travel with the request, so the server verifies a signature instead of doing a lookup. The tradeoff is real and cuts both ways. Sessions are trivial to revoke (delete the server record and the ID is instantly dead) but require shared storage across your servers. JWTs scale statelessly and work cleanly across services, but are hard to revoke before they expire — you issued a self-contained credential and can't easily un-issue it. For most single-app products, server sessions are simpler and safer; JWTs earn their complexity in distributed or cross-service systems.

What is the difference between HS256 and RS256?

Both are signing algorithms, but they differ in how many keys exist. HS256 is symmetric (HMAC-SHA256): one shared secret both creates and verifies the signature. Anyone who can verify a token can also forge one, so the secret must never leave the issuing server — fine when the same service signs and checks. RS256 is asymmetric (RSA-SHA256): a private key signs, and a separate public key verifies. You can hand the public key to any number of services and they can validate tokens without being able to mint them. Rule of thumb: HS256 when one service both issues and verifies (simplest, and the secret stays in one place); RS256 (or ES256) when a central auth server issues tokens that many independent services must verify. Never let the two be interchangeable at verification time — that's the key-confusion attack.

What is the 'alg: none' JWT attack?

It's a forgery that exploits libraries which trust the token's own header to decide how to verify it. The JWT header names its algorithm — {"alg":"HS256"} — and early/careless implementations read that field and act on it. An attacker changes the header to {"alg":"none"}, strips the signature entirely (leaving the trailing dot), and edits the payload to say whatever they want — "role":"admin". A vulnerable server sees alg:none, concludes 'no signature required', and accepts the forged token. The fix is absolute: never let the token dictate the algorithm. Your verification code must specify the exact algorithm(s) it will accept — jwt.verify(token, key, { algorithms: ['HS256'] }) — and reject everything else, including none. Reputable libraries now refuse alg:none unless you explicitly opt in, but the mistake still ships.

Where should I store a JWT — localStorage or a cookie?

Neither option is clean, which is why the debate never ends. localStorage is trivial to use but readable by any JavaScript on the page, so a single cross-site scripting (XSS) flaw lets an attacker steal the token — and JWTs are bearer tokens, meaning whoever holds one is treated as the user. An httpOnly cookie is invisible to JavaScript (much safer against XSS) but is sent automatically by the browser, which reintroduces CSRF risk unless you add SameSite=Strict/Lax and, for sensitive actions, anti-CSRF tokens. The modern consensus: prefer an httpOnly, Secure, SameSite cookie for the token; keep access tokens short-lived; and treat XSS as the root problem — if attackers can run JavaScript on your page, no token-storage choice saves you. Storing long-lived tokens in localStorage is the pattern to avoid.

How do refresh tokens and token expiry work?

Because a JWT can't easily be revoked, you keep the powerful part short-lived. The standard pattern is two tokens: a short-lived access token (minutes to an hour) sent on every request, and a long-lived refresh token (days to weeks) used only to obtain new access tokens. When the access token expires, the client presents the refresh token to a dedicated endpoint and gets a fresh access token — no re-login. This limits the damage window of a stolen access token to its short lifetime, while the refresh token, stored more carefully and revocable server-side, can be invalidated to log someone out. Rotate refresh tokens on each use and detect reuse of an old one (a signal of theft). The exp claim drives all of this — it's a Unix timestamp, and a converter is handy for reading it.

Which JWT claims must I validate before trusting a token?

A valid signature only proves the token wasn't altered — it does not mean the token is appropriate for your service right now. Always validate: exp (expiration) — reject expired tokens, allowing only a small clock-skew tolerance of maybe 30–60 seconds; nbf (not before) — reject tokens not yet valid; iss (issuer) — confirm the token came from the auth server you trust; and aud (audience) — confirm this token was minted for your service, not another one that happens to share a signing key. Skipping aud is how a token meant for service A gets replayed against service B. Also pin the accepted algorithm explicitly (see the alg:none answer). A signature check plus exp is the bare minimum; iss and aud are what stop cross-service replay, and any serious verification library validates all of them when you pass the expected values.

Share this post: