You log into a site. You click to your dashboard. Then your settings page. Then some deeply nested admin panel. Ten requests, ten different pages, and the server knows who you are the entire time. Never asks again.
But HTTP is stateless. Every single request is a blank slate. The server doesn't remember the last one. So how does this actually work?
Auth touches every app you'll ever build, and the choice between sessions and tokens has consequences most people don't think about until something breaks in production. Pick wrong and you're rewriting middleware six months in.
🧠HTTP forgets you between requests
Here's the actual problem. You send a request. The server responds. Connection done. Next request comes in and the server has zero memory of the previous one. It's like talking to someone with amnesia every single time.
Two ways to fix this:
The server remembers you. It creates a session, stores your info server-side (in memory, a database, Redis, whatever), and hands you a session ID in a cookie. Every request after that, you send the cookie, the server looks up your session. Works great until you have twelve servers behind a load balancer and the session lives on only one of them.
The request carries proof. Instead of the server storing anything, it gives you a signed token at login. Every request after that, you send the token. The server verifies the signature and trusts the data inside. No lookup needed.
That second approach is JWT.
What's actually inside a JWT
A JSON Web Token is three chunks of base64url-encoded data joined by dots:
header.payload.signature
That's it. Take a real one and split on the dots, then base64url decode each part:
// Header
{
"alg": "HS256",
"typ": "JWT"
}
// Payload
{
"sub": "user_8291",
"iat": 1691234567,
"exp": 1691238167,
"role": "admin"
}
The third part is the signature (a binary blob, not JSON).
Here's the thing people miss: base64url is encoding, not encryption. Anyone who has this token can decode the payload and read it. Paste one into jwt.io and see for yourself. So don't put passwords, credit card numbers, or anything sensitive in there. Ever.
Standard claims you'll see: sub (subject, usually user ID), exp (expiration, seconds since epoch), iat (issued at), iss (issuer), aud (audience).
The signature is the whole point
The server doesn't need to look anything up. It takes the header and payload, runs them through a signing algorithm with a secret, and compares the result to the signature in the token. Match? The token is legit and hasn't been tampered with. No match? Rejected.
Two common algorithms:
- HS256 (symmetric): one shared secret signs and verifies. Simple. But every service that needs to verify tokens must have the secret.
- RS256 (asymmetric): a private key signs, a public key verifies. Better when you have a separate auth service issuing tokens and many other services verifying them. The verifiers only need the public key.
Here's a Node.js example:
const jwt = require("jsonwebtoken");
const secret = "your-secret-key"; // obviously use something real
// Sign a token
const token = jwt.sign({ sub: "user_42", role: "editor" }, secret, {
expiresIn: "15m",
});
// Verify it (works)
const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });
console.log(decoded.sub); // "user_42"
// Tampered token (fails)
try {
jwt.verify(token + "x", secret, { algorithms: ["HS256"] });
} catch (err) {
console.log("Verification failed:", err.message);
}
Notice the algorithms: ["HS256"] option. That's not optional. I'll explain why later.
âš¡ The request flow
Honestly pretty simple once you see it laid out:
- Client sends credentials (username + password) to the login endpoint
- Server validates credentials, then signs a JWT containing the user's ID and any claims
- Server sends the token back to the client
- Client stores the token (more on where in a minute)
- On every subsequent request, client sends
Authorization: Bearer <token>in the header - Server verifies the signature and checks
exp - Request proceeds with the user's identity extracted from the payload
No session store. No database lookup. The token itself is the proof.
JWT versus sessions
| JWT | Sessions | |
|---|---|---|
| Where state lives | In the token (client-side) | Server-side (memory/DB/Redis) |
| Revocation | Hard. Token valid until expiry | Easy. Delete the session |
| Horizontal scaling | No shared state needed | Needs sticky sessions or shared store |
| Request size | Larger (token in every request) | Small (just a session ID cookie) |
| Lookup cost per request | None (signature math only) | One DB/cache read |
And here's my blunt take: revocation is the real weakness of JWTs. A signed token is valid until it expires. Full stop. "Log out everywhere" or "ban this user right now" doesn't work out of the box. You need extra machinery: short expiry windows plus refresh tokens, or a denylist of revoked tokens. But a denylist is server-side state, which is exactly what you were trying to avoid. Pick your tradeoff.
Refresh tokens and where to store the thing
The common pattern: a short-lived access token (5-15 minutes) paired with a longer-lived refresh token (days or weeks). When the access token expires, the client uses the refresh token to get a new one without making the user log in again.
But where do you actually store these?
- localStorage: accessible from JavaScript. Which means any XSS vulnerability on your page can steal it. One bad npm package, one injected script, and the token is gone.
- httpOnly cookie: JavaScript can't read it at all. Good. But cookies are sent automatically with every request to the domain, so you need SameSite attributes and CSRF protection.
There's no free option here. localStorage is XSS-exposed. Cookies need CSRF handling. Most production setups use httpOnly cookies with SameSite=Strict or Lax because XSS is harder to fully prevent than CSRF. But it depends on your app.
🎯 Ways people get this wrong
A real pitfalls list, because these aren't hypothetical:
-
Trusting the
algheader: old JWT libraries would read the algorithm from the token's header and use it. An attacker setsalgto"none"and the library skips verification entirely. Always pass an explicit list of allowed algorithms when verifying. -
Algorithm confusion attack: a server expects RS256 (asymmetric). The attacker takes the server's public key (which is, you know, public), signs a token using HS256 with that public key as the HMAC secret, and sends it. If the library reads
alg: "HS256"from the header and uses the public key for HMAC verification, it passes. The fix is the same: specifyalgorithms: ["RS256"]explicitly. -
Skipping expiry validation: if you don't check
exp, tokens live forever. Most libraries check it by default, but some don't. Verify. - Stuffing PII in the payload: remember, anyone can decode it. Don't put email addresses, phone numbers, or social security numbers in there.
-
Weak signing secrets:
"secret"or"password123"as your HMAC key. Use at least 256 bits of randomness. Actually, use a proper key management system.
📌 Takeaways
- JWT is signed, not encrypted. The payload is readable by anyone with the token.
- The server verifies by recomputing the signature, not by looking anything up.
- Stateless verification means you can't revoke tokens before expiry without adding server-side state back in.
- Always specify the expected algorithm when verifying. Never trust the token's
algheader. - Short-lived access tokens plus refresh tokens in httpOnly cookies is the balance most production apps land on.
Top comments (0)