What a JWT actually is
A JSON Web Token is a compact, URL-safe string that encodes a set of claims together with a signature over them. It is defined by RFC 7519 and built on two companion specifications: JSON Web Signature (JWS) for signing and JSON Web Encryption (JWE) for confidentiality. Almost every JWT you meet in the wild is a JWS.
The token is three Base64url-encoded parts joined by dots: header, payload and signature. It is self-contained, which means everything a server needs to make a decision travels with the request. Validating it requires no database lookup, no shared session store and no call to the issuer.
It is worth being precise about what that proves. A JWT demonstrates that whoever issued it signed this exact payload. It does not prove the token was meant for you unless you check the audience. It does not prove the user is still permitted to act unless you check scopes and roles. And it does not hide anything unless you use JWE. Every security property you care about has to be checked explicitly.
The three parts, decoded
The header is a small JSON object naming the signing algorithm and the token type. The alg field is the critical one. typ is almost always JWT, and kid identifies which key was used so that verifiers can find the right one during key rotation.
{ "alg": "HS256", "typ": "JWT", "kid": "2026-09" }
The payload is a JSON object of claims. Registered claims have standard names defined by the spec: iss for issuer, sub for subject, aud for audience, exp for expiration, nbf for not before, iat for issued at, and jti for a unique token id. Everything else is a custom claim you invent.
{
"iss": "https://auth.example.com",
"sub": "user_42",
"aud": "api.example.com",
"exp": 1760000000,
"iat": 1759999100,
"scope": "read:posts write:posts",
"role": "editor"
}
The signature is computed over the Base64url of the header and payload. Change one character in either and the recomputed signature will not match. This is the property that makes a JWT safe to hand to an untrusted client, and it is the only thing standing between a claim and a forgery.
Signed, not encrypted
The most common misunderstanding about JWTs is that they are secret. They are not. The payload is Base64url, which is an encoding, not encryption. Anyone holding the token can decode every claim with a single line of code.
const [, payload] = token.split(".");
console.log(JSON.parse(atob(payload)));
// { sub: "user_42", role: "editor", scope: "read:posts" }
That has two consequences. First, never put secrets, passwords or personal data in a JWT that you would not be comfortable showing the client. Second, treat the token itself as a credential: possession is enough to act as the subject, which is why storage and transport matter so much later in this guide.
If you genuinely need to hide the payload from the client, use JSON Web Encryption and a library that supports it. For the vast majority of systems, a signed JWS over TLS is the correct answer, and adding encryption only adds a moving part.
Signing algorithms: HS256 vs RS256
Two algorithm families dominate real deployments.
HS256 is HMAC with SHA-256. The same secret signs and verifies. It is fast, simple and ideal when a single service both issues and verifies its own tokens.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ role: "editor" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("user_42")
.setExpirationTime("15m")
.sign(secret);
await jwtVerify(token, secret, { algorithms: ["HS256"] });
RS256 is RSA with SHA-256, and ES256 is ECDSA over a prime curve. The issuer holds a private key and signs; everyone else holds the public key and verifies. That asymmetry is the reason large systems prefer it: a resource server can verify tokens without being able to mint them. ES256 provides the same guarantee with much smaller keys and signatures.
import { importPKCS8, importSPKI, SignJWT, jwtVerify } from "jose";
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY!, "RS256");
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY!, "RS256");
const token = await new SignJWT({ role: "editor" })
.setProtectedHeader({ alg: "RS256", kid: "2026-09" })
.setSubject("user_42")
.setExpirationTime("15m")
.sign(privateKey);
await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});
The rule that matters more than the choice: the verifier must pin the expected algorithm. Never let the token’s own header select how it is checked.
Claims: registered and custom
Registered claims are reserved by the specification and understood by every serious library.
iss— who issued the token. Check it to reject tokens from another environment.sub— who the token is about, usually the user id.aud— who the token is for. A token minted for your public API should not be accepted by your admin API.exp— when the token expires, as a Unix timestamp. Always set it.nbf— not before. Rarely needed, but useful for staged rollouts.iat— when it was issued. Handy for maximum-age checks and debugging.jti— a unique id for this token, used for replay detection and deny-lists.
Custom claims carry application data: scope, role, tenant_id, email. Keep them small and non-sensitive. A token travels on every request, so a bloated payload is a permanent tax on bandwidth and latency.
There is a strong temptation to put the user’s whole profile in the token to avoid a database read. Resist it. Claims go stale the moment a role changes, and you cannot un-issue a token that a client already holds. Put only what the verifier genuinely needs, and look up the rest.
Creating and verifying tokens
In Node, jose is the modern choice. It is promise-based, works in every runtime including Cloudflare Workers and Deno, and exposes a small, careful API. jsonwebtoken is the older, callback-flavoured library and remains common in existing code.
import { SignJWT } from "jose";
export async function issueAccessToken(userId: string, scope: string) {
return new SignJWT({ scope })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(userId)
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setIssuedAt()
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(new TextEncoder().encode(process.env.JWT_SECRET));
}
Verification is where security lives. A verifier must check the signature, pin the algorithm, and validate exp, iss and aud. A library will happily decode a token without verifying it, and that decoded payload is attacker-controlled input.
import { jwtVerify, type JWTPayload } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function verifyAccessToken(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});
return payload;
}
Notice that jwtVerify enforces exp and nbf automatically, but only checks iss and aud when you pass them. Leaving them out is a real vulnerability: a token minted for a staging service would be accepted by production, and a token minted for a different application would be accepted as well.
Access and refresh tokens
A single long-lived token is convenient and dangerous. The standard answer is a pair with different lifetimes and different audiences.
- The access token is short-lived, typically 5 to 15 minutes. It is sent on every API call and is the only token the resource server ever sees.
- The refresh token is long-lived, from days to months. It is sent only to the authorization server, in exchange for a fresh access token.
The split bounds the damage. If an access token leaks, it is useless within minutes. The refresh token, which is far more sensitive, never travels to resource servers and can be rotated and revoked, which is where the real control lives.
Refresh token rotation
Rotation means every refresh issues a new refresh token and invalidates the old one. If an attacker steals a refresh token and uses it, the legitimate client will later present an already-used token, and the server can detect the reuse and revoke the entire token family.
import { randomUUID } from "node:crypto";
export async function rotateRefreshToken(presented: string) {
const stored = await db.refreshToken.findUnique({ where: { token: presented } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
if (stored) await revokeFamily(stored.familyId);
throw new Error("invalid_refresh_token");
}
await db.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date(), replacedBy: randomUUID() },
});
return issueTokenPair(stored.userId, stored.familyId);
}
Store refresh tokens hashed, exactly as you would store passwords. A database leak should not hand an attacker working credentials, and a refresh token is nothing less than a password that bypasses the login form.
Where to store tokens in a browser
There is no perfect place, only trade-offs between cross-site scripting and cross-site request forgery.
- localStorage is readable by any script on the page. One XSS bug and the attacker exfiltrates every token. This is the mistake that keeps appearing in breach reports.
- In-memory, a variable in a module, is safe from persistent XSS but lost on refresh, so it is usually paired with a refresh token in an httpOnly cookie.
- An httpOnly, Secure, SameSite cookie is unreadable by JavaScript, which neutralises token theft via XSS. It reintroduces CSRF, which
SameSite=LaxorStrictplus a CSRF token mitigates.
For a browser application, the pragmatic default is a short-lived access token held in memory and a rotating refresh token in an httpOnly cookie scoped to the refresh endpoint. Native and server-side clients have no such constraint and can keep tokens in secure storage or simply in memory.
The stateless trade-off and revocation
The pitch for JWTs is statelessness. Any server can verify a token without shared state, which scales beautifully across regions and makes horizontal scaling trivial. The cost is that revocation is genuinely hard. A signed token is valid until it expires, whether or not you have since deleted the user, changed their role or logged them out. There is no central record to delete.
You can claw back some control:
- Keep access tokens short so the revocation window is minutes rather than days.
- Maintain a deny-list of
jtivalues for the rare immediate logout, checked on each request. That reintroduces state, so keep it small and expiring. - Add a per-user
token_versionclaim and reject tokens whose version is stale. Changing a password or role increments it.
This is the honest summary: JWTs trade easy revocation for easy scaling. If you need instant revocation everywhere, session cookies backed by a store may fit better, as covered in Session Auth.
Algorithm confusion and alg none
Two attacks are old enough to be textbook and still find victims.
The first is alg: none. An attacker edits the header to {"alg":"none"} and strips the signature. A naive verifier that trusts the header accepts the forged token. The second is HS/RS confusion. A service that expects RS256 tokens is tricked into accepting an HS256 token signed with the RSA public key, which is public by definition.
Both have the same fix: the verifier decides the algorithm, not the token.
// Good: the verifier decides.
await jwtVerify(token, secret, { algorithms: ["HS256"] });
// Bad: the token decides.
const { header } = decodeProtectedHeader(token);
await jwtVerify(token, secret, { algorithms: [header.alg] });
Reject alg: none outright, never derive the key from an untrusted source, and treat the header as data, not as instructions.
Scopes and authorization
Authentication answers who, and scopes answer what they may do. A scope claim is a space-delimited list of permissions, and middleware checks it before a handler runs.
export function requireScope(required: string) {
return (req, res, next) => {
const granted = String(req.user.scope ?? "").split(" ");
if (!granted.includes(required)) {
return res.status(403).json({ error: "insufficient_scope" });
}
next();
};
}
Keep scopes coarse and stable, and enforce them on the server. A token without the right scope must fail with 403, not 401: the caller is authenticated, just not allowed. For richer access models built on roles and attributes, see RBAC.
JWT vs opaque tokens
A JWT is a bearer token that carries its own validation. An opaque token is a random string with no meaning, so the server looks it up to learn anything.
Opaque tokens win on revocation and privacy. You can delete the session instantly, and the token reveals nothing if it leaks. They cost a database or cache round-trip on every request. JWTs win on scale and independence. Services verify locally and need no shared store, at the price of a revocation window.
Many production systems use both: a JWT access token for speed and an opaque refresh token for control. That hybrid is what most identity providers ship today, and it is a good default when you cannot decide.
Key rotation with kid
Signing keys should not live forever. Rotation limits the damage of a compromised key, and the kid header field is what makes rotation invisible to clients: the verifier reads kid, selects the matching key, and checks the signature.
import { SignJWT, jwtVerify, createLocalJWKSet } from "jose";
const jwks = createLocalJWKSet({
keys: [{ kty: "oct", kid: "2026-09", k: process.env.JWT_SECRET }],
});
// The verifier resolves the key from the header's kid.
await jwtVerify(token, jwks, { algorithms: ["HS256"] });
When you rotate, publish the new key next to the old, sign new tokens with the new kid, and keep verifying the old key until every token signed with it has expired. Remove it too early and you log out every active user at once. With asymmetric keys, publish a JWKS document at a well-known URL and let resource servers cache it.
Token lifetimes in practice
Expiry is a dial between security and convenience. There is no universal right answer, but the shape of a sensible default is consistent.
- Access tokens: 5 to 15 minutes. Short enough that a leak is quickly worthless, long enough that you are not refreshing on every request.
- Refresh tokens: 7 to 30 days, with rotation and a sliding window. Long-lived enough to keep users signed in, short enough that an abandoned token eventually dies.
- Absolute session limit: 30 to 90 days. A maximum age after which the user must authenticate again, no matter how often they refresh.
If your users complain about being logged out, the fix is a smoother silent refresh, not a longer access token. A 24-hour access token is a revocation outage waiting to happen.
Debugging a token without trusting it
When a request fails with 401, you want to see what the token claims without weakening verification. Decoding is safe as long as you treat the result as untrusted data.
import { decodeJwt, decodeProtectedHeader } from "jose";
const header = decodeProtectedHeader(token);
const claims = decodeJwt(token);
console.log({ alg: header.alg, kid: header.kid });
console.log({
sub: claims.sub,
iss: claims.iss,
aud: claims.aud,
exp: new Date((claims.exp ?? 0) * 1000).toISOString(),
expired: (claims.exp ?? 0) * 1000 < Date.now(),
});
The two failures you will see most are an aud mismatch after a service rename and an iss mismatch after moving between environments. Both are configuration problems, and both are invisible until you print the claims.
Testing tokens
You should be able to test an authenticated route without standing up an identity provider. Because a JWT is just a signed string, a test helper that signs a token with the same test secret is enough.
import { SignJWT } from "jose";
import request from "supertest";
import app from "../app.js";
const secret = new TextEncoder().encode("test-secret");
async function tokenFor(scope = "read:posts") {
return new SignJWT({ scope })
.setProtectedHeader({ alg: "HS256" })
.setSubject("user_1")
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setExpirationTime("5m")
.sign(secret);
}
test("rejects a request without a token", async () => {
await request(app).get("/posts").expect(401);
});
test("accepts a request with a valid token", async () => {
const token = await tokenFor();
await request(app)
.get("/posts")
.set("authorization", `Bearer ${token}`)
.expect(200);
});
Also test the negative cases: an expired token, a token with the wrong audience, and a token signed with a different key. Those are the checks that protect you, so they deserve coverage as much as the happy path.
Best practices
- Always set
exp; keep access tokens to 15 minutes or less. - Pin the algorithm on the verifier and reject
alg: none. - Validate
iss,aud,expandnbf; never trust a claim you did not check. - Keep secrets and private keys out of source control and load them from a secret manager.
- Store refresh tokens hashed and rotate them on every use.
- Keep claims small and non-sensitive; the payload is readable.
- Prefer httpOnly cookies or memory over localStorage in browsers.
- Plan for revocation with short lifetimes, a
jtideny-list or a token version. - Use
josefor new code; it is promise-based and portable across runtimes.
Common mistakes
- Assuming the payload is encrypted because it looks like gibberish.
- Reading claims with
decodeand treating them as verified. - Letting the token’s
algheader choose the verification algorithm. - Accepting a token without checking
aud, so staging tokens work in production. - Using a weak or shared secret, or committing it to the repository.
- Setting expiry to 30 days because refreshing is annoying.
- Storing tokens in localStorage and calling it done.
- Putting a role in the token and never invalidating it when the role changes.
- Treating a 401 and a 403 as the same error.
Where to go next
JWTs are one tool in a larger identity toolkit. The OAuth 2.0 guide shows how tokens are actually obtained through delegated authorization, Session Auth covers the cookie-based alternative when you need instant revocation, and API Keys explains long-lived credentials for machine clients. If you want to see this verification code inside a real server, read Node.js.