A signed token a server hands you after login: that's a JWT. Here's how it works and the key points for using it safely (no attack steps).
How it works (three parts)
A JWT is header.payload.signature joined with .. The header and payload are just base64url-encoded (not encrypted); only the signature is made with a secret (or shared) key.
1. header
meta like the signing algorithm. anyone can read it
2. payload
claims like user id and expiry. anyone can read = no secrets
3. signature
made with a key. guarantees no tampering
The key point is that the contents are "just readable." So the payload is not a place for passwords or personal data. A JWT's value is that by verifying the signature, the server can confirm "I issued this token and it hasn't been altered."
Defenses for using it safely
Always verify the signature, and pin the alg (most important)
Verify the signature server-side every time. Pin the accepted signature algorithm (alg) to the value you expect, and reject alg:none (no signature). Don't trust the alg written in the token's header blindly.
The JWT best-practices RFC (RFC 8725) states that libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms, and that all cryptographic operations in the JWT must be validated, with the entire JWT rejected if any of them fail.
Block algorithm switching, not just alg:none
Abusing alg is not only about removing the signature. RFC 8725 describes how changing RS256 to HS256 makes some libraries try to validate the signature using HMAC-SHA256 with the RSA public key as the shared secret (algorithm confusion). The public key is, by definition, public — so if that works, an attacker can create a valid signature themselves.
The defense is the same: do not trust the header's alg; pin the algorithm the server expects. Decide the algorithm first and then verify, rather than picking a verification routine from the key material you were handed.
Validate who it is for, and until when
A valid signature only tells you the token is genuine. Whether it is for you and still current is a separate check.
RFC 7519 states that for aud, "if the principal processing the claim does not identify itself with a value in the 'aud' claim when this claim is present, then the JWT MUST be rejected", and that exp "identifies the expiration time on or after which the JWT MUST NOT be accepted for processing" (nbf works the same way at the other end). RFC 8725 adds that the application must validate that the keys used belong to the issuer. A token legitimately issued for a different service getting accepted by yours is what skipping these looks like.
Put no secrets in the payload
Assume anyone can decode the contents. Keep secrets — passwords, personal data, API keys — out of it. Include only claims that aren't catastrophic if seen, like identifiers and permissions.
Keep expiry short, and have a way to revoke
Make access tokens short-lived. An over-long expiry stretches the damage window if one is stolen. If you need revocation, pair a short-lived access token with a server-managed refresh/session.
Use a strong key and store it where it can't be stolen
Make the signing key strong, don't reuse it, and store it somewhere safe server-side. Protect the token itself too — keep it from being stolen via XSS, carry it in a cookie with safe attributes (→ beware theft via XSS).
This site's view: a JWT is not a session mechanism for every use
JWTs are often used to "hold login state," but they have a weakness: revocation is hard. If the signature is valid and unexpired, the server basically trusts it, so making a logout or invalidation take effect immediately is awkward.
Our stance is: don't use a JWT as a long-lived substitute for a session. Combine a short-lived access token with a server-side, revocable refresh/session, and you get both the JWT's upside (cheap verification) and easy revocation.
Caution: "it decoded" is not "it's valid"
Because the contents are readable, anyone can paste a JWT into a JWT decoder and inspect the header and payload. But decoding (reading the contents) and verifying (confirming authenticity) are entirely different. Being able to decode guarantees nothing about validity. What decides whether a token is genuine is always the server-side signature verification. Reading contents for debugging is handy — just don't mistake "I could read it" for "it's valid."
JWT vs. server-side sessions (cookies)
A JWT is just one way to hold login state, and it fits some cases better than a server-side session and others worse.
| Item | Server-side session (cookie) | JWT |
|---|---|---|
| Where state lives | On the server (DB or memory); the cookie holds only an ID | Inside the token; the server only verifies the signature |
| Logout / suspension | Deleting it on the server takes effect at once | Accepted until expiry unless you build revocation separately |
| Visibility of contents | Only an ID, so nothing to read | Anyone can decode and read it |
| Good fit | An ordinary web app on a single domain | Several services or APIs verifying the same token |
For a web app that lives on one domain, a server-side session is often enough. If you do choose JWTs, issuing a fresh token at login is the same principle as the defense against session fixation.
A real case: a library that didn't check the algorithm
Algorithm switching isn't just theory. The widely used Node.js JWT library jsonwebtoken, in versions 4.2.1 and earlier, didn't properly check the algorithm named in the token header, which allowed verification to be bypassed (CVE-2015-9235, fixed in 4.2.2). A token meant to be verified with a public key could be signed with a shared-key (HS-family) algorithm instead and still pass — exactly the algorithm confusion described above.
Two lessons:
- Keep the library up to date. You can check your dependencies for known vulnerabilities by pasting a lockfile into the dependency vulnerability scanner
- Don't rely on library defaults; state the accepted algorithm explicitly in your own code
How to check your own tokens
Paste a token your own service issued into the JWT decoder and look at the following. Decoding runs in your browser and the token is not sent to a server. Even so, for a live production token it's sensible to log out afterward so it's invalidated.
- Is the header's
algwhat you expect (notnone)? - Is there an
exp, and is the lifetime fromiat(issued-at) appropriate for the use? - Are
audandisspresent and pointing at your service? - Does the payload contain anything you wouldn't want seen — email addresses, phone numbers, API keys?
Check server-side verification with tests in a development environment. A token with one signature character changed, a token with a different alg, an expired token, and a token meant for another service (wrong aud) should all be rejected — keep that as an automated test.
Sources (primary)
- IETF RFC 8725, "JSON Web Token Best Current Practices" — datatracker.ietf.org (pinning algorithms, validating every cryptographic operation, algorithm confusion, keys belonging to the issuer)
- IETF RFC 7519, "JSON Web Token (JWT)" — datatracker.ietf.org (the normative definitions of
exp,nbfandaud) - GitHub Advisory Database, "Verification Bypass in jsonwebtoken" (CVE-2015-9235) — github.com/advisories (4.2.1 and earlier affected; fixed in 4.2.2)
- OWASP HTML5 Security Cheat Sheet — cheatsheetseries.owasp.org (don't store session identifiers in localStorage)
Read next
- Tool: JWT decoder / inspector (check contents, spot alg:none or expiry)
- Design: password reset design flaws (common mistakes on the token-issuing side)
- Glossary: what XSS is (a classic token-theft route) / what CSRF is / what session fixation is / what public-key cryptography is
- Learn: choosing MFA the right way