JWT (JSON Web Tokens) Complete Guide 2026: Authentication Explained from Scratch

Every modern application needs authentication. JWT is the most widely-deployed stateless auth standard in 2026 — powering APIs at Google, Stripe, GitHub, and millions of services worldwide. Yet most developers copy-paste JWT code without understanding how it works, when to use it, or the security traps that get real apps compromised. This guide covers everything: the exact anatomy of a token, the full sign → store → verify → refresh flow, production-ready Node.js code, a React auto-refresh hook, algorithm selection (HS256 vs RS256 vs ES256), and the five JWT security mistakes you must avoid in production.

73%

of public APIs use JWT as their primary authentication mechanism in 2026

3

parts in every JWT — header · payload · signature — separated by dots

15m

recommended access token lifetime — anything longer is a security risk

0

database lookups required to verify a JWT — purely cryptographic

1

Definition: What Is a JSON Web Token?

JWT = a compact, self-contained, cryptographically signed proof of identity

A JSON Web Token is a URL-safe string that encodes a set of “claims” — facts about a user or system — and signs them so they cannot be modified without detection. The signature is mathematical proof: alter even one character in the payload and the entire signature becomes invalid. No database lookup required on the verifying server — just a cryptographic computation that takes microseconds.

Every JWT is three Base64URL-encoded strings joined by dots (header.payload.signature). Paste any JWT into jwt.io and it instantly separates into three sections. Important: Base64URL encoding is not encryption — the payload is readable by anyone holding the token.

textJWT anatomy — a real token decoded into its three parts
// A JWT (access token, shortened for readability)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  .eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxNjAwMDAwMCwiZXhwIjoxNzE2MDAwOTAwfQ
  .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

// ── PART 1: Header ────────────────────────────────────────────────────────
{
  "alg": "HS256",    // Signing algorithm — HMAC-SHA256
  "typ": "JWT"       // Token type
}

// ── PART 2: Payload (Claims) ──────────────────────────────────────────────
{
  "sub": "user_123",         // Subject — who this token represents
  "role": "admin",           // Custom claim — your business data
  "plan": "pro",             // Custom claim — subscription tier
  "iat": 1716000000,         // Issued At — Unix seconds
  "exp": 1716000900,         // Expires At — exactly 15 minutes later
  "iss": "api.myapp.com"    // Issuer — who created this token
}

// ── PART 3: Signature ─────────────────────────────────────────────────────
// HMACSHA256(
//   base64url(header) + "." + base64url(payload),
//   SECRET_KEY
// )
// If header or payload changes by even 1 bit → signature mismatch → rejected

JWTs are encoded, not encrypted — never put secrets in the payload

The header and payload are Base64URL-encoded, which any developer can decode in two seconds. Only store non-sensitive identifiers: user ID, role, subscription plan, and standard claims. Never store passwords, API keys, credit card data, or private PII. The signature proves the data has not been tampered with — it does not hide the data from the token holder.
2

When to Use JWT — and When Not To

JWT is a tool, not a universal solution. Choosing the wrong auth mechanism causes both security problems and unnecessary complexity. Here is a clear decision guide.

✅ Stateless microservices

Each service can verify identity independently using only the public key (RS256) or shared secret (HS256) — no round-trip to a central auth server per request. This is the core value of JWT: zero shared session state between services at runtime.

✅ Mobile and single-page apps

SPAs and native mobile apps do not have a server-side session store. JWT in memory (SPA) or platform secure storage (mobile Keychain / Android Keystore) is the natural fit. The Authorization header crosses domains cleanly — no cookie scoping issues.

✅ Cross-domain and third-party APIs

Cookies are domain-scoped and require careful SameSite configuration. JWT in the Authorization header works uniformly across all domains, CDNs, and third-party integrations — no CORS cookie preflight complications.

❌ When you need instant session revocation

You cannot revoke a JWT before it expires without a blocklist database — which reintroduces statefulness. Banking apps, medical records, or any session that must be killed the moment a user reports a stolen device: use server-side sessions where you can delete the session row immediately.

❌ Server-rendered apps with native session support

Rails, Django, Laravel, and Next.js server components already have excellent HttpOnly cookie sessions. Adding JWT to a server-rendered app adds complexity with no benefit. Use the platform session mechanism and save JWT for API layers.

❌ Storing sensitive data in the token

If the content of the token must be kept secret from the client, you need JWE (JSON Web Encryption) — a different, more complex standard. Plain JWT is not the tool for carrying private data. Use it only for data the client is allowed to see.

3

How JWT Authentication Works — The Complete Flow

JWT Auth Flow — from Login to Every Protected API Request

POST /auth/login

Client sends credentials. Server verifies bcrypt/argon2 hash. Wrong password → 401 immediately.

Server signs two tokens

On success: access token (15 min, HS256/RS256) + refresh token (7 days, different secret, includes jti claim).

Tokens delivered to client

Access token: JSON response body. Refresh token: HttpOnly + Secure + SameSite=Strict cookie — JS cannot read it.

Client stores access token

In-memory only: React state, a closure, or a ref. NEVER localStorage. NEVER sessionStorage.

Every API request

Authorization: Bearer <access_token>. Server verifies signature — pure math, zero DB lookup, sub-millisecond.

Access token expires → 401

Client detects TOKEN_EXPIRED code. Calls POST /auth/refresh. Browser auto-sends HttpOnly cookie.

New access token issued

Server validates refresh token + checks revocation list (Redis). Issues new access token. Client retries original request.

Logout

Server deletes refresh token from store. Client clears in-memory access token. Old access token valid max 15 min — acceptable trade-off.

4

How to Implement JWT in Node.js — Production Code

javascriptjwt-auth.js — sign, verify middleware, refresh handler
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';

const ACCESS_SECRET  = process.env.JWT_ACCESS_SECRET;   // 256-bit random string
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;  // DIFFERENT secret from access

// ── Sign — called on successful login ─────────────────────────────────────
function signTokens(userId, role) {
  const accessToken = jwt.sign(
    { sub: userId, role, type: 'access' },
    ACCESS_SECRET,
    { expiresIn: '15m', issuer: 'api.myapp.com' }
  );

  const refreshToken = jwt.sign(
    {
      sub: userId,
      type: 'refresh',
      jti: randomBytes(16).toString('hex'), // unique ID for revocation
    },
    REFRESH_SECRET,
    { expiresIn: '7d', issuer: 'api.myapp.com' }
  );

  return { accessToken, refreshToken };
}

// ── Middleware — runs on every protected route ─────────────────────────────
function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token', code: 'NO_TOKEN' });
  }

  try {
    const payload = jwt.verify(header.slice(7), ACCESS_SECRET, {
      issuer: 'api.myapp.com',
      // jwt.verify throws automatically on: expired, bad signature, wrong issuer
    });

    if (payload.type !== 'access') {
      return res.status(401).json({ error: 'Wrong token type', code: 'WRONG_TYPE' });
    }

    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    const code = err.name === 'TokenExpiredError' ? 'TOKEN_EXPIRED' : 'INVALID_TOKEN';
    return res.status(401).json({ error: err.message, code });
  }
}

// ── Refresh endpoint — POST /auth/refresh ─────────────────────────────────
async function refreshHandler(req, res) {
  const refreshToken = req.cookies.refreshToken; // HttpOnly cookie, auto-sent
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
      issuer: 'api.myapp.com',
    });

    // Check revocation (Redis) — catches stolen refresh tokens that were already used
    const isRevoked = await redis.get(`revoked:${payload.jti}`);
    if (isRevoked) {
      return res.status(401).json({ error: 'Refresh token revoked', code: 'REVOKED' });
    }

    // Rotate: invalidate old refresh token, issue new one (refresh token rotation)
    await redis.set(`revoked:${payload.jti}`, '1', 'EX', 7 * 86400);

    const { accessToken, refreshToken: newRefreshToken } = signTokens(
      payload.sub,
      await getUserRole(payload.sub)
    );

    // Set new refresh token cookie
    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,
    });

    res.json({ accessToken });
  } catch {
    res.status(401).json({ error: 'Invalid refresh token', code: 'INVALID_REFRESH' });
  }
}
typescriptuseAuthFetch.ts — React hook with automatic silent token refresh
import { useCallback, useRef } from 'react';

// Drop-in replacement for fetch() — handles token injection and auto-refresh
export function useAuthFetch() {
  const tokenRef = useRef<string | null>(null);

  const authFetch = useCallback(async (url: string, init: RequestInit = {}) => {
    const headers = new Headers(init.headers);

    if (tokenRef.current) {
      headers.set('Authorization', `Bearer ${tokenRef.current}`);
    }

    let res = await fetch(url, { ...init, headers });

    // On token expiry — silently refresh and retry once
    if (res.status === 401) {
      const body = await res.clone().json().catch(() => ({}));

      if (body.code === 'TOKEN_EXPIRED') {
        const refreshRes = await fetch('/api/auth/refresh', {
          method: 'POST',
          credentials: 'include', // sends the HttpOnly refresh cookie automatically
        });

        if (refreshRes.ok) {
          const { accessToken } = await refreshRes.json();
          tokenRef.current = accessToken;
          headers.set('Authorization', `Bearer ${accessToken}`);
          res = await fetch(url, { ...init, headers }); // retry original request
        }
      }
    }

    return res;
  }, []);

  return {
    authFetch,
    setToken:   (token: string) => { tokenRef.current = token; },
    clearToken: ()              => { tokenRef.current = null;  },
  };
}

// Usage in a component:
// const { authFetch, setToken } = useAuthFetch();
// setToken(accessTokenFromLogin);
// const data = await authFetch('/api/profile').then(r => r.json());
5

Why JWT — Algorithm Choice and the Security Model

The algorithm you choose determines your entire security model for token verification. Getting this wrong in a multi-service architecture means a single compromised service can forge tokens for every other service.

FactorHS256 (HMAC-SHA256)RS256 (RSA-SHA256)ES256 (ECDSA-P256)
Key typeSymmetric — one shared secretAsymmetric — private signs, public verifiesAsymmetric — elliptic curve key pair
Key distributionEvery verifier shares the secret — riskyPublic key safe to distribute freelyPublic key safe to distribute freely
PerformanceFastest — single HMAC hashSlowest — RSA math is expensiveFast — elliptic curve is efficient
Token sizeSmallest signatureLargest (2048-bit RSA signature)Small (comparable to HS256)
Compromise impactOne leaked secret = all tokens invalidOnly private key matters — public key is safeOnly private key matters — public key is safe
Best forSingle server / trusted monolithMicroservices, third-party API consumersMobile, IoT, high-throughput modern APIs
2026 verdictOK for simple appsStandard for distributed systemsPreferred for new architectures

5 JWT security rules that stop 95% of attacks

1. Short access token TTL (15 minutes max) — bounds the damage window of any stolen token. 2. Access token in memory only — never localStorage, never sessionStorage. XSS exposes both. 3. HttpOnly cookie for refresh token — the browser sends it automatically; JavaScript cannot read it. 4. Rotate refresh tokens on every use — detect stolen tokens when the legitimate user next refreshes. 5. Validate the alg header — explicitly reject tokens where alg is “none”; this is a documented attack.
6

JWT vs Session Cookies vs OAuth 2.0 — Decision Chart

FeatureJWT StatelessSession CookiesOAuth 2.0
Server storageNone — zero runtime stateSession store (Redis / DB)Auth server stores tokens
Instant revocation❌ Needs a blocklist (Redis)✅ Delete one row — immediate✅ Revoke at auth server
Cross-domain APIs✅ Authorization header works anywhere❌ Cookie domain scoping complications✅ Designed for cross-domain
Mobile apps✅ No cookie jar — Authorization header⚠️ Possible but awkward✅ Standard OAuth PKCE flow
Microservices✅ Each service verifies independently❌ Needs sticky sessions or shared Redis✅ Centralized at auth server
Third-party login❌ Not its purpose❌ Not its purpose✅ Purpose-built for this
Best use caseStateless APIs, SPAs, mobile appsServer-rendered apps, admin dashboards"Sign in with Google" / B2B SSO

Paste any malformed JWT payload, JSON auth error response, or API JSON — our AI Error Explainer identifies every syntax issue with plain-English explanations and one-click auto-fix.

Open JSON Error Explainer →

Frequently Asked Questions