Skip to content

Zoom-in: JWT

Karify98·
Cover Image for 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.

Problem left: need something the client can carry on every request to prove it already authenticated.

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.

Problem left: the session store is a centralized bottleneck — every request hits the DB, every server must connect to the same place.

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
Header Metadata: signing algorithm (HS256, RS256...) — Base64 encoded, not encrypted
Payload Claims: user_id, role, exp (expiry)... — Base64 encoded, not encrypted
Signature Digital signature: 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.

Problem left: the signature prevents tampering — but why can't someone just forge it?

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