NodeJSJSON Web Tokens (JWT)

JSON Web Tokens (JWT)

A JWT is a compact, signed, self-contained token that carries claims about a user. Instead of the server storing session state and handing out an opaque pointer (as with sessions), the server signs a token containing the user's identity and gives it to the client. On each request the client presents the token; the server verifies the signature and trusts the claims inside — no database lookup required. That statelessness is JWT's superpower and its biggest footgun. This page explains the anatomy, the signature, and the security rules before you implement it.

Anatomy: three Base64URL parts

Text
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIn0 . 3xH8...sig
└────── HEADER ──────┘ └──────────── PAYLOAD ────────────┘ └─ SIGNATURE ─┘

HEADER    { "alg": "HS256", "typ": "JWT" }      ← which algorithm
PAYLOAD   { "sub": "42", "role": "user", "exp": 1719000000 }  ← the claims
SIGNATURE HMAC/RSA over (header + "." + payload) using the secret/key
The payload is ENCODED, not ENCRYPTED — anyone can read it
The header and payload are merely **Base64URL-encoded**, not encrypted — paste a JWT into jwt.io and you'll see the claims in plaintext. The signature only guarantees the token hasn't been *tampered with*, not that it's *secret*. So **never put sensitive data** (passwords, full PII, secrets) in a JWT payload. Put an opaque user id and the minimum claims needed for authorization, and assume the client and any network observer can read all of it.
The signature is the whole point
A valid signature proves the token was issued by you and is unchanged
When you issue a JWT you sign `header.payload` with a secret (HMAC, **HS256**) or a private key (RSA/ECDSA, **RS256**). On each request you recompute/verify that signature. If even one byte of the payload changed — say a client edits `"role":"user"` to `"role":"admin"` — the signature no longer matches and verification fails. This is how the server can *trust* the claims without storing them: it proves they're exactly what the server signed. Guard the signing secret/private key like a database password; whoever has it can forge any token.
Standard claims

Claim

Meaning

sub

Subject — who the token is about (user id)

iat

Issued At — when it was created

exp

Expiration — after this, verification fails

iss

Issuer — who created it

aud

Audience — who it’s intended for

jti

Unique token id — useful for denylists

Always set `exp` — a token without expiry is valid forever
A JWT is valid until it expires. If you omit `exp`, the token is good **forever** — and since JWTs are stateless, you can't easily un-issue one. A leaked never-expiring token is permanent account access. Always set a **short** expiry on access tokens (minutes to an hour) and verify `exp` on every request. To keep users logged in without long-lived access tokens, pair a short access token with a [refresh token](/nodejs/refresh-tokens).
The infamous `alg: none` and algorithm confusion

JS
// VERIFY with an explicit allow-list of algorithms — never trust the header's alg:
jwt.verify(token, secret, { algorithms: ['HS256'] })   // ✅ pinned

// ❌ NEVER do this — lets the attacker pick the algorithm:
jwt.verify(token, secret)                               // alg taken from token header
Pin the algorithm on verify — `alg: none` and HS/RS confusion are real attacks
Two classic JWT exploits abuse trusting the token's own `alg` header. **`alg: none`**: an attacker strips the signature and sets `alg` to `none`; a naive verifier accepts it as "unsigned but valid." **Algorithm confusion**: a server expecting RS256 (public/private key) is tricked into treating the *public* key as an HMAC secret by switching `alg` to HS256 — and the public key is, well, public. Defense for both: pass an explicit `algorithms` allow-list to `verify` and reject anything else. Never let the attacker-controlled header choose how you verify.
Where to store the token client-side

Storage

XSS risk

CSRF risk

Notes

localStorage

High — JS-readable, stealable

Low

Convenient but XSS = token theft

HttpOnly cookie

Low — JS can’t read it

Needs SameSite/CSRF token

Safer; behaves like a session cookie

In-memory only

Lower (gone on reload)

Low

Combine with a refresh flow

`localStorage` tokens are stealable by any XSS — prefer HttpOnly cookies
Storing a JWT in `localStorage` is common in tutorials but means **any** [XSS](/nodejs/security-intro) vulnerability hands the attacker your token. An `HttpOnly` cookie can't be read by JavaScript, neutralizing token theft via script — but cookies are sent automatically, so you must add `SameSite` and [CSRF](/nodejs/cookies-middleware) protection. There's no perfect option; for browser apps, `HttpOnly` cookies (or keeping the access token in memory and refreshing it) are generally safer than `localStorage`.
JWT vs session — pick deliberately
  • Stateless — no per-request DB lookup; great for APIs, mobile, and microservices.

  • Cross-service — any service holding the key (or public key) can verify without a shared session store.

  • Hard to revoke — a valid token works until exp; instant logout needs a denylist or short expiry + refresh.

  • Don’t reach for JWT by default for a classic first-party web app — sessions revoke more cleanly.

JWT's strength (statelessness) is also its weakness (revocation)
Because nothing is stored server-side, a JWT can't simply be deleted to log someone out — it stays valid until it expires. People "solve" this by adding a server-side denylist of revoked `jti`s... which reintroduces the very state JWTs were meant to avoid. The pragmatic pattern: keep **access tokens short-lived** so the revocation window is tiny, and use a tracked, revocable [refresh token](/nodejs/refresh-tokens) for longevity. Choose JWT for stateless APIs; choose [sessions](/nodejs/sessions) when clean revocation matters most.
Next
Wire it up end-to-end in Express: [Implementing JWT Auth](/nodejs/jwt-implementation).