Zoom-in: JWT

Log in, call an API, server lets you through. This is the auth mechanism AI generates thousands of times a day without explaining why it works.
graph LR
C(["👤 Client"]) -->|"token"| S(["🖥️ Server"])
S -->|"200 OK"| C
style C fill:#1e293b,stroke:#475569,color:#cbd5e1
style S fill:#1e293b,stroke:#475569,color:#cbd5e1
Zoom in on the black box.
Layer 1 — The root problem: server doesn't remember
HTTP is stateless — every request is independent, the server has no memory of the previous one. A user just logged in, but the next request arrives with no context of who's asking.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: POST /login (email + password)
S-->>C: OK, but server forgets immediately after
C->>S: GET /dashboard
S-->>C: Who are you? 401 Unauthorized
Something needs to let the client "prove identity" in every request — without resending the password each time.
Layer 2 — Traditional sessions: server remembers for the client
The classic solution: after login, the server creates a random session ID, stores it in a database, then sends it back to the client via cookie.
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: POST /login
S->>DB: Store session {id: "abc123", user_id: 42}
S-->>C: Set-Cookie: session=abc123
C->>S: GET /dashboard (Cookie: session=abc123)
S->>DB: Look up session "abc123"
DB-->>S: {user_id: 42}
S-->>C: 200 OK — data for user 42
Works well — but every request requires a database query to validate the session. With multiple servers (horizontal scaling), every server needs access to the same session store.
Layer 3 — JWT: the self-contained token
JWT flips the approach: instead of the server storing state, the token carries its own information and proves its own validity.
A JWT has 3 parts separated by .:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0MiwiZXhwIjoxNzUwMDAwMDAwfQ.xK9mF2...
HEADER PAYLOAD SIGNATURE
HS256, RS256...) — Base64 encoded, not encrypted
user_id, role, exp (expiry)... — Base64 encoded, not encrypted
HMAC(header + payload, secret_key) — guarantees the token wasn't tampered with
Anyone can Base64-decode the header and payload to read the content. What protects the token isn't encryption — it's the signature.
Layer 4 — The signature: why it can't be forged
The server signs the token with a secret key — a random string only the server knows:
signature = HMAC-SHA256(base64(header) + "." + base64(payload), SECRET_KEY)
When receiving a token, the server recalculates the signature from the header and payload, then compares it with the one in the token:
graph TD
Req["GET /dashboard\nAuthorization: Bearer eyJ..."] --> Split["Split token\nheader | payload | signature"]
Split --> Recalc["Recalculate:\nHMAC(header + payload, SECRET_KEY)"]
Recalc --> Compare{"Compare\nsignatures"}
Compare -->|"Match + not expired"| OK["200 OK — user_id=42"]
Compare -->|"Mismatch / expired"| Fail["401 Unauthorized"]
style Req fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
style Split fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style Recalc fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style Compare fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style OK fill:#1a3a2a,stroke:#22c55e,color:#86efac
style Fail fill:#3b1a1a,stroke:#ef4444,color:#fca5a5
If someone modifies the payload (e.g., changes user_id from 42 to 1), the signature won't match — because they don't have the SECRET_KEY to re-sign it. The server rejects it immediately.
Full picture
From cookie/session to stateless JWT.
graph TD
Login["POST /login\nemail + password"] --> Verify["Verify\ncredentials"]
Verify --> Sign["Sign JWT\nHMAC(header+payload, SECRET)"]
Sign --> Client["Client\nstores token"]
Client --> Req["GET /dashboard\nBearer eyJ..."]
Req --> Check["Server verifies\nrecalculates signature"]
Check -->|"Match + not expired"| Allow["200 OK"]
Check -->|"Mismatch / expired"| Deny["401 Unauthorized"]
style Login fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
style Client fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
style Req fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
style Verify fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style Sign fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style Check fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style Allow fill:#1a3a2a,stroke:#22c55e,color:#86efac
style Deny fill:#3b1a1a,stroke:#ef4444,color:#fca5a5
Three common misconceptions
JWT is encrypted
Header and payload are only Base64 encoded — not encrypted. Anyone with the token can read the content. Don't store sensitive data (passwords, card numbers...) in the payload. The signature only ensures the token wasn't modified — it doesn't keep the content secret.
Storing JWT in localStorage is fine
localStorage is accessible to any script on the page, making it vulnerable to XSS attacks. An httpOnly cookie cannot be accessed from JavaScript, making it the safer option for long-lived tokens. A lot of AI-generated auth code defaults to localStorage without explaining this trade-off.
JWT tokens can't be revoked
True by design — the server stores no state, so there's no list of valid tokens to delete from. The practical solution: use a short exp (15 minutes) combined with a refresh token. Or maintain a blocklist of revoked tokens — at which point a DB is needed again, losing part of the stateless advantage.
Takeaway
JWT exists to solve the centralized session store problem — not to be more secure than session cookies. The HMAC signature ensures the token can't be forged; Base64 doesn't hide the data. Stateless has a price: tokens can't be revoked, so short expiry and refresh tokens are mandatory.
When debugging auth, the right question: "did the token expire, was it tampered with, or was it correctly rejected?" — exp, the signature, and the 401 vs 403 distinction will point to which layer has the problem.
Article assisted by Amy 🌸 - AI Assistant. Content reviewed by the author.
Related Posts
Zoom-in: OAuth 2.0
'Sign in with Google' hides a delegation mechanism where your password never leaves Google. OAuth 2.0 solves the authorization problem without sacrificing security.
Zoom-in: Asymmetric Encryption
HTTPS is secure because it's encrypted. But who encrypted it, who decrypts it, and why no one can impersonate the server — that's the real question.
Zoom-in: TCP
Every HTTP request runs on TCP — but before the first byte of real data crosses the wire, three packets are exchanged carrying no data at all. TCP solves the problem the Internet doesn't.