How JWT works: authentication, signing and security
How JWT works in practice: the three parts of the token, what the signature guarantees, HS256 vs RS256, the login flow, expiration and security pitfalls.
You opened the DevTools of a production app, copied the token it sends with every request, and pasted it into a decoder. Three seconds later you are reading the user's email, the id, the "admin" role, and the expiration date, all in plain text. Nobody gave you permission, nobody needed the key. And that is exactly how JWT was designed to work. The problem is not that the token is readable: it is how many people think it is encrypted.
This article explains how JWT works in practice: the three parts of the token, what the signature actually guarantees, the difference between HS256 and RS256, the login flow, the cost of being stateless, and the security mistakes that still take down APIs today.
Anatomy: three parts separated by a dot
A JWT is a string with three Base64URL blocks separated by dots: header.payload.signature. Every block is decodable by anyone, with no key at all. Base64URL is not encryption, it is encoding.
The header states which algorithm signs the token and its type:
{
"alg": "HS256",
"typ": "JWT"
}
The payload carries the claims, which are statements about the user and about the token itself:
{
"sub": "1234567890",
"name": "Ana Souza",
"role": "admin",
"iat": 1721827200,
"exp": 1721830800
}
sub is the subject (who the user is), iat is the issued-at time, exp is when the token expires. The third part, the signature, is the result of signing base64url(header) + "." + base64url(payload) with a key. If you decode a token from one of your own sessions right now, you will see the header and payload in plain text. You can inspect a token and read its claims without sending anything to a server, which is great for debugging and terrible if you put sensitive data there thinking it was hidden.
What the signature guarantees (and what it does not)
This is where the most common misunderstanding lives. The signature guarantees integrity and authenticity: if someone changes a single character of the payload, the signature no longer matches and the server rejects the token. And it guarantees that whoever issued it held the key. That is all.
The signature does not guarantee confidentiality. The payload is not secret. Anyone with the token reads everything. The practical rule: never put a password, card number, government id, or any secret in a JWT payload. Put an id and fetch the rest on the server. If you genuinely need an unreadable payload, that is what JWE (JSON Web Encryption) is for, a separate standard and rarely what people mean when they say "JWT".
HS256 vs RS256: shared secret or key pair
The header's alg decides how the token is signed and verified, and the choice has architectural consequences.
HS256 is symmetric: it uses a single shared secret both to sign and to verify. Simple, fast, and fine when the same service issues and validates tokens. The trouble shows up when you have several services that need to validate: they all need the secret, and any one of them, if compromised, can forge valid tokens.
RS256 is asymmetric: it uses a private key to sign and the matching public key to verify. The auth server keeps the private key; any microservice can validate with the public key while never being able to forge a token. For distributed systems, federation, or when third parties must validate your tokens, RS256 (or ES256) is the sensible choice. Start with HS256 if you are a monolith; move to RS256 when the topology demands it.
The authentication flow in practice
The standard cycle has four steps. The user logs in with credentials. The server validates them, assembles the payload with claims and expiration, signs it, and returns the token. The client stores that token and, on every subsequent request, sends it in the header:
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
The server receives it, verifies the signature, checks exp, reads the claims, and decides whether to authorize. Notice what it does not do: it does not query a session database. All the information it needs is inside the token. That is the entire point of JWT and also the source of its problems. If you are designing the API that receives this Bearer, it is worth reviewing the principles of good REST API design so you do not bolt stateless auth onto an inconsistent route design.
Stateless has a cost: revocation
Because the server keeps no session state, it has no "log out now" button. A valid token is valid until it expires, full stop. A user's account is compromised and you want to kill the session immediately? With pure JWT you cannot, because the server does not even know that token exists.
Every workaround reintroduces some state: keeping a blocklist of revoked tokens (and now you are querying a store on every request again), using short expirations to limit the window, or versioning the credential and checking a flag. None of them is free. Anyone promising "stateless auth" usually has not reached the revocation requirement yet.
Expiration and refresh tokens
The practical defense against a leaked token is a short exp. An access token living 5 to 15 minutes limits the damage. But nobody wants to log in again every 15 minutes, and that is where the refresh token comes in: a second, long-lived token, stored more carefully, whose only job is to request new access tokens.
The pair works like this: the short access token goes in every request; when it expires, the client uses the refresh token to obtain a new access token without re-authenticating. The refresh token, being long-lived, is the valuable target, which is why it deserves the most secure storage you have and ideally rotation on every use (one-time use, treating reuse as a sign of theft).
Where to store the token (opinion)
There are two practical options in the browser and they are not equivalent.
localStorage is convenient and it is the wrong choice for sensitive apps. Any XSS on your site reads the entire localStorage and exfiltrates the token. A single compromised dependency in your bundle and your tokens leak.
A httpOnly + Secure + SameSite cookie is not accessible from JavaScript, so an XSS cannot read the token directly. The cost is having to deal with CSRF (mitigated by SameSite=Strict/Lax and anti-CSRF tokens when needed). For anything that touches real user data, prefer the httpOnly cookie. Use localStorage only for low-value tokens where an XSS would have sunk you anyway.
The classic security pitfalls
Two vulnerabilities survive to this day because they came from the design itself.
alg: none. The spec allowed for an "unsigned" token with alg set to none. A library that honors that value blindly accepts a token where the attacker stripped the signature and edited the payload at will. Mitigation: your verification must explicitly require the expected algorithm and reject none.
Algorithm confusion (RS256 -> HS256). If the server uses RS256 but the library picks the algorithm from the token's header, an attacker switches alg to HS256 and signs using the public key (which is, after all, public) as if it were the HMAC secret. Misconfigured libraries verify it successfully. Mitigation, again: pin the accepted algorithm on the server; never trust the alg that came inside the token.
The lesson from both cases is the same: never let the token choose how it will be verified.
Frequently asked questions
Is a JWT encrypted?
Not by default. A plain JWT (JWS) is only signed and Base64URL-encoded, which means anyone with the token reads the entire payload. The signature protects against tampering, not against reading. If you need an unreadable payload, the standard is JWE, which is separate and far less common.
Can I revoke a JWT before it expires?
Not with pure stateless JWT. The server keeps no record of the token, so there is no way to invalidate it on demand. The solutions (blocklist, short expiration, credential versioning) always reintroduce some server-side state. That is why short-lived access tokens combined with refresh tokens are the standard approach.
What is the difference between HS256 and RS256?
HS256 is symmetric: the same secret signs and verifies, ideal for a single service. RS256 is asymmetric: the private key signs and the public key verifies, letting multiple services validate without being able to forge tokens. For distributed systems, RS256 (or ES256) is the safer choice.
Should I store the JWT in localStorage?
For sensitive apps, no. localStorage is readable by any JavaScript, so an XSS steals the token instantly. Prefer a httpOnly, Secure, SameSite cookie, which JavaScript cannot read, and handle the CSRF risk separately.
Takeaway
A JWT is a signed, readable envelope, not a safe. The signature proves where it came from and that it was not tampered with; it hides nothing. Treat the payload as public, never keep it in localStorage for sensitive data, pin the verification algorithm on the server to close alg: none and algorithm confusion, and accept that stateless means giving up instant revocation, offset with short exp and refresh tokens. Decode your own tokens once in a while: whatever you see in there, everyone else sees too.
- 01 UTF-8 and encoding problems: why accents break and how to fix them What UTF-8 is, code points, mojibake, and why accented text turns to garbage — the mechanism explained end to end.
- 02 MIT, Apache, and GPL Licenses: Practical Differences for Developers MIT, Apache 2.0, and GPL are not the same thing. Understand permissive vs copyleft, patent protection, and compatibility before shipping code to production.