JWT β€” JSON Web Tokens

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: A signed chunk of JSON used to prove who someone is β€” without the server having to remember anything. The wristband-at-a-concert metaphor: once issued, anyone with the secret can verify it.


In plain English

A JWT (pronounced β€œjot”) is a self-contained piece of identity information that a client can carry around and present whenever it needs to prove who it is. Picture a concert wristband: once you’ve shown ID at the gate and they snap the wristband on, you don’t need to show ID again β€” the wristband itself is proof, and any security guard can verify it without phoning the box office.

A JWT is three Base64-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.signature_here
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜.β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜.β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
   header             payload              signature
  1. Header β€” metadata: what algorithm signed this (e.g. HS256, RS256).
  2. Payload β€” the actual claims: { "sub": "user_123", "email": "...", "exp": 1717200000 }. Base64-encoded, not encrypted β€” anyone can read it. Don’t put secrets in here.
  3. Signature β€” a cryptographic signature of the first two parts, made with a secret key that only the server knows.

When a user logs in, the server creates a JWT and sends it to the client. The client stores it and presents it on subsequent requests β€” usually in an Authorization: Bearer <token> header. The server verifies the signature to ensure the token hasn’t been forged or tampered with. No database lookup needed.

That stateless property is what makes JWTs so popular for APIs, microservices, and serverless functions.

This entry expands on the glossary entry at j.md#jwt with implementation patterns, security considerations, and real-world use.


Why it matters

  • The default token format for modern auth. Supabase Auth issues JWTs. Auth0, Clerk, NextAuth β€” all use JWTs. Mobile apps, SPAs, microservices β€” JWTs.
  • Stateless verification scales. No database lookup per request. Any server with the secret can verify. Cheap to run, easy to scale.
  • Information packaging. A JWT can carry user ID, role, permissions, expiry β€” all signed and verifiable.
  • Cross-system trust. One service issues a JWT; another verifies it without coordination. Foundation of modern microservices auth.

The three parts in detail

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg β€” the signing algorithm. HS256 (HMAC + SHA-256, symmetric β€” same key signs and verifies) or RS256 (RSA, asymmetric β€” private key signs, public key verifies).
  • typ β€” always β€œJWT.”

Base64-encoded β†’ first segment.

Payload (the claims)

{
  "sub": "user_12345",
  "name": "George",
  "role": "user",
  "iat": 1717200000,
  "exp": 1717203600
}

Standard claims (defined in the JWT spec):

  • sub β€” subject (typically user ID)
  • iss β€” issuer (who created this token)
  • aud β€” audience (who this token is for)
  • iat β€” issued-at (Unix timestamp)
  • exp β€” expiration (Unix timestamp; after this, treat as invalid)
  • nbf β€” not-before (don’t trust until this time)

Plus any custom claims you add: role, email, org_id, whatever you need.

Base64-encoded β†’ second segment. Anyone can decode and read this. Never put secrets here.

Signature

HMACSHA256(
  base64(header) + "." + base64(payload),
  secret_key
)

The signature is what makes the token tamper-proof. Change one character in the payload, and the signature no longer matches. The server rejects the token.

Base64-encoded β†’ third segment.


Verifying a JWT

The server (or any party with the secret) verifies in three steps:

  1. Split the token by . into header, payload, signature.
  2. Compute the expected signature using header + payload + secret.
  3. Compare to the signature in the token. If they match, the token is genuine.

If the token is genuine AND the exp claim is in the future, trust the claims.

In code (Node + jsonwebtoken):

import jwt from "jsonwebtoken";
 
const token = req.headers.authorization?.split(" ")[1];
try {
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  // decoded.sub is the user ID; trust it
  req.user_id = decoded.sub;
} catch (err) {
  // Token invalid or expired
  return res.status(401).end();
}

HS256 vs RS256 β€” symmetric vs asymmetric

HS256 (HMAC)

  • One secret key. Same key signs and verifies.
  • Simpler. Fine for a single application or trusted backend.
  • Secret must be shared with every server that verifies tokens.

RS256 (RSA)

  • Private/public keypair. Private key signs; public key verifies.
  • More complex. Useful when MANY services need to verify but ONE service signs.
  • Public key can be widely distributed; private key stays secret.

For a typical Next.js + Supabase app, HS256 is fine. For a microservices architecture, RS256 is common.


Where to store JWTs in the browser

Three options, each with trade-offs:

localStorage (common, vulnerable)

  • Easy to read via JavaScript
  • Vulnerable to XSS β€” attacker JS can steal the token
  • Persists across tabs and reloads
  • Not recommended for auth tokens in production

sessionStorage

  • Same XSS vulnerability as localStorage
  • Lost when tab closes
  • Rarely the right choice
  • Inaccessible to JavaScript
  • Sent automatically on requests
  • Vulnerable to CSRF (mitigated by SameSite=Lax)
  • The right answer for most webapps

For Supabase Auth in Next.js, the @supabase/ssr package handles cookie storage. You don’t manage tokens manually β€” the SDK does.

For SPAs hitting your own API, in-memory storage + refresh tokens is a defensible pattern. Token lives in JS memory, lost on reload, refreshed silently via a cookie-based refresh token.


A concrete example: issuing and verifying

Issue at login

import jwt from "jsonwebtoken";
 
async function login(email: string, password: string) {
  const user = await db.users.findUnique({ where: { email } });
  if (!user) throw new Error("Not found");
  if (!await bcrypt.compare(password, user.password_hash)) throw new Error("Invalid");
 
  const token = jwt.sign(
    {
      sub: user.id,
      email: user.email,
      role: user.role
    },
    process.env.JWT_SECRET!,
    {
      expiresIn: "15m"
    }
  );
 
  return { token, refresh: await issueRefreshToken(user.id) };
}

Verify on protected route

function authMiddleware(req: Request) {
  const auth = req.headers.get("authorization");
  if (!auth) throw new Error("No token");
 
  const token = auth.replace("Bearer ", "");
  const decoded = jwt.verify(token, process.env.JWT_SECRET!);
 
  return decoded; // { sub, email, role, iat, exp }
}

That’s the whole pattern. Short-lived access tokens (15-60 minutes) + longer-lived refresh tokens to silently renew. Most auth services handle this internally.


Refresh tokens β€” handling expiry without re-login

A JWT can’t be invalidated before its exp β€” the signature is still valid until then. So you can’t make tokens long-lived (risk of theft) AND easily revoke them.

Solution: short access tokens + longer refresh tokens.

  • Access token β€” short-lived (15 min - 1 hour). Used on every request. Discarded when expired.
  • Refresh token β€” long-lived (days to months). Stored more securely (HttpOnly cookie). Used to get new access tokens silently.

When the access token expires, the client (or library) calls /refresh with the refresh token. Server issues a new access token. User stays logged in without re-entering credentials.

Refresh tokens are usually opaque (random IDs, server-side stored) rather than JWTs β€” that way the server can invalidate them by deleting the record.


Common attack patterns and mitigations

alg: none attack

Old JWT libraries accepted tokens with "alg": "none" β€” treating them as unsigned and trusting their contents. Catastrophic. Modern libraries reject this by default. Verify yours does.

Algorithm confusion (HS vs RS)

Attacker takes an RS256-signed token, changes the header to HS256, and signs with the public key as the HMAC secret. Bug-prone libraries accept this. Modern libraries reject; verify yours does.

Token theft via XSS

If your JWT is in localStorage and attacker JS runs, theft is instant. HttpOnly cookies mitigate.

Replay attacks

Stolen token works until expiry. Mitigations: short expiry, refresh tokens, IP/user-agent binding (limited effectiveness), token revocation lists.

Signature stripping

Token altered, signature removed entirely. Library should reject anything without valid signature. Verify it does.


Storing data in JWTs β€” best practices

  • Include only what you need. Token size affects every request. A JWT with the user’s entire profile is bloated.
  • Stable identifiers only. Use user_id, not username (usernames change).
  • No secrets. Payload is readable.
  • Set reasonable expiry. 15 min - 1 hour for access tokens; longer with refresh.
  • Include role/permissions carefully. They’re cached for the token lifetime. Demoting a user takes up to exp to take effect.

When NOT to use JWTs

  • You need easy revocation. JWTs are hard to revoke before expiry. Server-side sessions are easier.
  • Token size matters. JWTs can grow large; opaque session IDs are tiny.
  • No need for distributed trust. Single app, single database? Server-side sessions are simpler.
  • You’re storing them insecurely. localStorage = XSS risk. Use HttpOnly cookies or stay with traditional sessions.

Modern Supabase Auth uses JWTs but stores them in cookies β€” combining the stateless benefits of JWT with the security of cookies. Best of both.


Common gotchas

  • Putting secrets in payload. Anyone can decode the payload. NEVER put passwords, API keys, or PII you don’t want public.

  • Trusting alg: none tokens. Catastrophic. Verify your library rejects them.

  • Algorithm confusion (HS vs RS). Use a library that doesn’t accept signed-with-public-key-as-HS256 trickery. Most modern libraries handle this; verify.

  • Storing in localStorage exposes to XSS. Use HttpOnly cookies for browser auth.

  • Long-lived access tokens. A 30-day access token, stolen, gives an attacker 30 days. Use short access + refresh tokens.

  • No expiry (exp). Token valid forever. A leaked token is forever a backdoor. Always include exp.

  • Clock skew between servers. Server A issues token with exp 5 minutes ahead; Server B’s clock is 6 minutes ahead β†’ rejects valid token. Use NTP; allow small skew.

  • Forgetting to verify aud/iss. Token issued for app A reused on app B. Verify the audience and issuer match.

  • Not invalidating refresh tokens on suspicious activity. User reports suspicious login β†’ revoke all refresh tokens for that user.

  • Trusting JWT claims without re-checking role. User demoted yesterday still has admin role in their JWT. For sensitive operations, re-check from DB.

  • Larger and larger payloads. β€œWhile we’re at it, include all permissions in the token.” JWT grows; every request balloons; performance suffers. Keep payload minimal.

  • Bearer tokens in URLs. Token in URL = leaks to server logs, referrer headers, browser history. Use headers or cookies.

  • Different JWT versions. Auth0, Supabase, NextAuth, custom β€” each has their own claim conventions. Read the docs for the one you’re using.

  • Asymmetric key (RS256) management. Private key leak = anyone can forge tokens. Rotate carefully; never commit to Git.

  • CORS issues with Authorization header. Browser blocks cross-origin requests with auth headers unless the server explicitly allows. Configure CORS.

  • Forgetting to send token on subsequent requests. Token issued at login but not added to fetch headers. Bug.

  • Using JWT for sessions in a monolithic single-server app. Overkill. Just use sessions.


See also

Sources