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
- Header β metadata: what algorithm signed this (e.g.
HS256,RS256). - Payload β the actual claims:
{ "sub": "user_123", "email": "...", "exp": 1717200000 }. Base64-encoded, not encrypted β anyone can read it. Donβt put secrets in here. - 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
Header
{
"alg": "HS256",
"typ": "JWT"
}algβ the signing algorithm.HS256(HMAC + SHA-256, symmetric β same key signs and verifies) orRS256(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:
- Split the token by
.into header, payload, signature. - Compute the expected signature using header + payload + secret.
- 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
HttpOnly cookies (recommended)
- 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
expto 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: nonetokens. 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 includeexp. -
Clock skew between servers. Server A issues token with
exp5 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
Authorizationheader. 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
- Authentication vs authorization π© β JWTs prove authn
- Sessions & cookies π© β alternative storage mechanism
- Passwords & hashing π© β password verifies, then JWT issues
- OAuth & social login π₯ β OAuth issues JWTs
- XSS π₯ β main attack vector for localStorage JWTs
- OWASP top 10 π© β broken auth is #1
- Secrets management π₯ β JWT signing keys are secrets
- Supabase π© π¦ β uses JWTs for auth
- How-to: Add Supabase auth π©
- Glossary entry: JWT β short summary
Sources
- RFC 7519 β JSON Web Token β the official spec
- jwt.io β paste a JWT to decode and inspect
- Auth0 β JWT introduction
- OWASP β JSON Web Token Cheat Sheet
- Supabase Auth β JWTs
jsonwebtokennpm package