Authentication

JWT

A JSON Web Token is a signed, URL-safe string that carries claims about a user. Compact and self-contained, it scales beautifully and is easy to get wrong.

intermediate15 min readUpdated Sep 16, 2026
verify.ts
ts
// verify.ts
import { jwtVerify, type JWTPayload } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

export async function verify(token: string): Promise<JWTPayload> {
  const { payload } = await jwtVerify(token, secret, {
    issuer: "https://auth.example.com",
    audience: "api.example.com",
    algorithms: ["HS256"],
  });
  return payload;
}
First spec
2015
Encoding
Base64url
Default algorithm
HS256
Asymmetric option
RS256 / ES256
Encrypted variant
JWE

Why it matters

What a JWT buys you

Self-contained credentials

Every claim a service needs travels inside the token, so validation is a local operation with no database round-trip on the hot path.

Tamper-evident by design

The signature covers the header and payload, so changing a single character invalidates the token and verification fails loudly.

A natural fit for rotation

Pairing short-lived access tokens with rotating refresh tokens bounds the damage of a leak and gives you a path back to revocation.

The big picture

The three layers of a token

A header names the algorithm, a payload carries claims, and a signature seals both against tampering.

Header

Declare

A tiny JSON object naming the signing algorithm and key id, so verifiers can look up the right key during rotation.

Payload

Carry

A JSON object of registered and custom claims such as sub, exp, iss, aud, scope and role.

Signature

Seal

A cryptographic MAC or signature over the encoded header and payload that makes the token tamper-evident.

At a glance

Inside the token

Header

alg, typ and kid describe how the token was signed.

Claims

Registered names like iss, sub, aud and exp plus your own fields.

Signature

HMAC or RSA/ECDSA output that verification recomputes and compares.

Expiry

The exp claim limits the window in which a stolen token is useful.

Rotation

Refresh tokens are single-use and replaced on every exchange.

Verification

Pin the algorithm and check iss, aud and exp before trusting anything.

Flow

How a JWT is verified

Every protected request walks the same path. Any failed check ends in a 401.

  1. 1

    Extract the token

    Read the Authorization header and require the Bearer scheme. Reject the request if no token is present.

  2. 2

    Split the token

    Break the string on dots into header, payload and signature. A token without exactly three parts is malformed.

  3. 3

    Verify the signature

    Recompute the signature over the first two parts with the key and the pinned algorithm. Mismatch means the token was tampered with.

  4. 4

    Validate the claims

    Check exp and nbf for timing, iss for the expected issuer, and aud for the intended audience. A staging token must not pass in production.

  5. 5

    Attach the principal

    On success, place the subject and scope on the request so downstream handlers can authorize without re-parsing the token.

  6. 6

    Reject with 401

    If any step fails, return 401 with a generic error. Do not explain which check failed to an untrusted caller.

A short history

How JWTs became the default

  1. 2011

    The JWT draft appears

    The OAuth working group proposes a compact token format for carrying claims between services.

    11
  2. 2015

    RFC 7519 standardises JWT

    JWT is published alongside JWS, JWE, JWK and JWA, giving the format a stable specification.

    15
  3. 2015

    OpenID Connect adopts it

    ID tokens are defined as JWTs, making the format the default for identity providers.

    15
  4. 2015

    The classic attacks land

    Researchers document "alg: none" and HS/RS confusion, forcing verifiers to pin algorithms.

    15
  5. 2020

    Rotation becomes the norm

    Short access tokens and rotating refresh tokens replace long-lived tokens in mainstream practice.

    20

The complete guide

JWT: Everything you need to know

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.

Anatomy of a JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwic2NvcGUiOiJyZWFkOnBvc3RzIiwiZXhwIjoxNzYwMDAwMDAwfQ.3vJ1o0Q8m4nQ7bKc9xP2sT6wYqL0dRfH8uZgWmA1eJk
headerthe algorithm and token type, Base64url
payloadthe claims, also Base64url and readable by anyone
signatureHMAC over the first two parts, proving they were not changed

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=Lax or Strict plus 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 jti values for the rare immediate logout, checked on each request. That reintroduces state, so keep it small and expiring.
  • Add a per-user token_version claim 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, exp and nbf; 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 jti deny-list or a token version.
  • Use jose for 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 decode and treating them as verified.
  • Letting the token’s alg header 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.

In practice

Issue, verify, rotate, inspect

The four operations you will write first, using jose.

tokens.ts
import { SignJWT } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

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(secret);
}

HS256 vs RS256

Symmetric is simpler when one service issues and verifies. Asymmetric is worth the setup once verification spreads across services.

HS256
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
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"] });

httpOnly cookie vs localStorage

JavaScript cannot read an httpOnly cookie, which removes the most common path from an XSS bug to full account takeover.

Prefer
Set-Cookie: access_token=eyJhbGciOi...;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=900
Avoid
localStorage.setItem("access_token", token);

// Any injected script can now read the token and
// exfiltrate it. Persistent XSS becomes account takeover.

Trade-offs

Is stateless auth worth it?

JWTs trade easy revocation for easy scaling. Decide which one your product actually needs.

Strengths

  • No shared session store

    Any instance can verify a token with a key it already has, so horizontal scaling and multi-region deployments stay simple.

  • Compact and portable

    One header carries identity, scopes and expiry across services, languages and runtimes without a translation layer.

  • Great for service-to-service

    Independent services can verify tokens locally, which removes a synchronous dependency on the authorization server.

Trade-offs

  • Revocation is the hard part

    A signed token is valid until it expires. Logging out a user or revoking a role cannot reach a token already in a client's hands.

  • The payload is public

    Base64url is not encryption. Anything you put in the claims is readable by the holder and by anyone who intercepts it.

  • Small mistakes are severe

    Trusting the header's algorithm, skipping the audience check or storing tokens in localStorage each turn a convenience into a breach.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning JWT (JSON Web Tokens)?

Our interactive tutorial walks you through JWT (JSON Web Tokens) step by step — with quizzes and real code you can run in the browser.