[2026] JWT authentication complete guide — structure, access & refresh tokens, security, Next.js & Express

[2026] JWT authentication complete guide — structure, access & refresh tokens, security, Next.js & Express

이 글의 핵심

A practical guide to JWT authentication: how tokens are built and verified, the access + refresh pattern, security practices (secrets, HttpOnly, expiry, revocation), and full Express and Next.js examples.

At a glance

This is a practical guide to applying JWT authentication: from how JWTs work to access and refresh tokens, security practices, and Next.js/Express examples.

Production note: When moving a ~100k concurrent-user service from session-based auth to JWTs, we cut server memory use by about 60% and improved auth latency by about 3×—experience shared for context.

Introduction: how do we keep users “logged in”?

Real-world pain points

Scenario 1: session memory pressure

Storing sessions in server memory does not scale as users grow. JWTs avoid per-request server session state and scale more easily.

Scenario 2: microservices

Sharing server sessions across services is hard. JWTs let each service validate a token without a shared session store.

Scenario 3: mobile clients

Cookie-based sessions are awkward in many mobile setups. JWTs can be sent in Authorization headers.

sequenceDiagram
    participant Client as Client
    participant Server as Server
    participant DB as Database
    Client->>Server: Login (ID/password)
    Server->>DB: Verify user
    DB-->>Server: User record
    Server->>Server: Issue JWT
    Server-->>Client: Access token + refresh token
    Client->>Server: API request (+ access token)
    Server->>Server: Verify token
    Server-->>Client: Response

1. What is a JWT?

JWT structure

A JWT (JSON Web Token) is a string in Header.Payload.Signature form.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

1. Header

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg: signing algorithm (e.g. HS256, RS256)
  • typ: token type (JWT)

2. Payload

{
  "userId": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622
}
  • iat (issued at): issued time
  • exp (expiration): expiry time
  • Custom claims: userId, name, etc.

3. Signature

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

JWT internals — JWS, signing, claims, and operations

The string commonly called a JWT is a compact serialization of RFC 7519 JSON Web Token claims using RFC 7515 JWS (JSON Web Signature). Each segment is Base64URL-encoded JSON (no padding; +// substitutions), and the signature is the cryptographic output of applying the header’s alg to the signing input.

Base64URL and the trust boundary
The signing input is base64url(header) + "." + base64url(payload). jwt.decode (or equivalent) does not verify the signature — anyone can change the payload and append garbage. In production you must jwt.verify (or validate JWS explicitly): check signature, expiry, and required claims before trusting anything.

HS256 vs RS256
HS256 shares one symmetric secret between issuer and verifier. It is simple for a single API, but if the secret leaks, attackers can forge and verify tokens. RS256 signs with a private key and verifies with a public key, so resource servers and gateways need only the public half — typical for microservices and API gateways. Public keys are often distributed via JWKS endpoints.

Verification pipeline (what a verifier does)
(1) Split on . and reject malformed tokens. (2) Read alg and reject disallowed algorithms (especially none) to prevent algorithm-confusion attacks. (3) Recompute the signature with the shared secret or IdP public key and compare with timing-safe equality. (4) Only after a valid signature, enforce exp, nbf, and iat. (5) Optionally enforce iss and aud so tokens meant for another API cannot be replayed.

Registered, public, and private claims
iss, sub, aud, exp, nbf, iat, and jti are registered claims. jti uniquely identifies a token and pairs well with denylists or refresh rotation and reuse detection. Custom claims (role, tenant_id, …) are still only Base64 — treat the payload as readable by anyone; never embed passwords, card data, or excessive PII.

Refresh strategies
Keep access tokens short (e.g. 15 minutes) and refresh tokens longer (e.g. 7–30 days). Rotate refresh tokens: one-time use, issue a new pair each refresh. If the same refresh token appears twice, treat it as theft and invalidate the session family (reuse detection). Store refresh jti (or a hash) in Redis/DB and delete on logout.

Production patterns (summary)

PatternPurpose
Validate audEnsure the token is for this API
iss + JWKSVerify with the right IdP keys and rotate keys safely
jti + denylistLogout and revocation without guessing expiry
Separate secretsDifferent keys for access vs refresh tokens

The sections below show how these ideas map to Node.js jsonwebtoken, Express, and Next.js.


2. Creating and verifying JWTs

Node.js (jsonwebtoken)

npm install jsonwebtoken
// Issue JWTs
import jwt from 'jsonwebtoken';
const SECRET_KEY = process.env.JWT_SECRET;
function generateAccessToken(userId) {
  return jwt.sign(
    { userId },
    SECRET_KEY,
    { expiresIn: '15m' } // 15 minutes
  );
}
function generateRefreshToken(userId) {
  return jwt.sign(
    { userId },
    SECRET_KEY,
    { expiresIn: '7d' } // 7 days
  );
}
// Example
const accessToken = generateAccessToken('user123');
const refreshToken = generateRefreshToken('user123');
// Verify JWT
function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, SECRET_KEY);
    return { valid: true, decoded };
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return { valid: false, error: 'Token expired' };
    }
    if (error.name === 'JsonWebTokenError') {
      return { valid: false, error: 'Invalid token' };
    }
    return { valid: false, error: 'Token verification failed' };
  }
}
// Example
const result = verifyToken(accessToken);
if (result.valid) {
  console.log('User ID:', result.decoded.userId);
} else {
  console.error('Error:', result.error);
}

3. Access token + refresh token pattern

Why two tokens?

flowchart TB
    subgraph Problem[Access token only]
        A1[Short TTL: frequent logins]
        A2[Long TTL: higher risk]
    end
    subgraph Solution[Access + refresh]
        B1[Access: short TTL, e.g. 15m]
        B2[Refresh: long TTL, e.g. 7d]
        B3[Refresh when access expires]
    end
    Problem --> Solution

Implementation

// server.js
import express from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
const app = express();
app.use(express.json());
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
// Refresh token store (use Redis in production)
const refreshTokens = new Set();
// Login
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  // Look up user (DB in real apps)
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { userId: user.id },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
  refreshTokens.add(refreshToken);
  res.json({ accessToken, refreshToken });
});
// Refresh access token
app.post('/api/auth/refresh', (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }
  if (!refreshTokens.has(refreshToken)) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
  try {
    const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
    const accessToken = jwt.sign(
      { userId: decoded.userId },
      ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );
    res.json({ accessToken });
  } catch (error) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
});
// Logout
app.post('/api/auth/logout', (req, res) => {
  const { refreshToken } = req.body;
  refreshTokens.delete(refreshToken);
  res.json({ message: 'Logged out' });
});
// Auth middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }
  try {
    const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(403).json({ error: 'Invalid or expired token' });
  }
}
// Protected route
app.get('/api/user/profile', authenticateToken, (req, res) => {
  res.json({ userId: req.user.userId, email: req.user.email });
});

4. Next.js implementation

API routes

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { prisma } from '@/lib/prisma';
export async function POST(request: NextRequest) {
  const { email, password } = await request.json();
  const user = await prisma.user.findUnique({ where: { email } });
  if (!user) {
    return NextResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) {
    return NextResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    process.env.ACCESS_TOKEN_SECRET!,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { userId: user.id },
    process.env.REFRESH_TOKEN_SECRET!,
    { expiresIn: '7d' }
  );
  const response = NextResponse.json({ accessToken });
  response.cookies.set('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60, // 7 days
  });
  return response;
}
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
export async function POST(request: NextRequest) {
  const refreshToken = request.cookies.get('refreshToken')?.value;
  if (!refreshToken) {
    return NextResponse.json(
      { error: 'Refresh token required' },
      { status: 401 }
    );
  }
  try {
    const decoded = jwt.verify(
      refreshToken,
      process.env.REFRESH_TOKEN_SECRET!
    ) as { userId: string };
    const accessToken = jwt.sign(
      { userId: decoded.userId },
      process.env.ACCESS_TOKEN_SECRET!,
      { expiresIn: '15m' }
    );
    return NextResponse.json({ accessToken });
  } catch (error) {
    return NextResponse.json(
      { error: 'Invalid refresh token' },
      { status: 403 }
    );
  }
}

Client (React)

// lib/auth.ts
let accessToken: string | null = null;
export async function login(email: string, password: string) {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  if (!response.ok) {
    throw new Error('Login failed');
  }
  const data = await response.json();
  accessToken = data.accessToken;
  return data;
}
export async function refreshAccessToken() {
  const response = await fetch('/api/auth/refresh', {
    method: 'POST',
  });
  if (!response.ok) {
    throw new Error('Refresh failed');
  }
  const data = await response.json();
  accessToken = data.accessToken;
  return data.accessToken;
}
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
  if (!accessToken) {
    throw new Error('Not authenticated');
  }
  let response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  });
  if (response.status === 403) {
    try {
      await refreshAccessToken();
      response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${accessToken}`,
        },
      });
    } catch (error) {
      window.location.href = '/login';
      throw error;
    }
  }
  return response;
}
// components/LoginForm.tsx
'use client';
import { useState } from 'react';
import { login } from '@/lib/auth';
export default function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await login(email, password);
      window.location.href = '/dashboard';
    } catch (error) {
      alert('Login failed');
    }
  };
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      <button type="submit">Log in</button>
    </form>
  );
}

5. Security practices

1. Secret management

# .env
ACCESS_TOKEN_SECRET=your-very-long-random-secret-key-at-least-256-bits
REFRESH_TOKEN_SECRET=another-different-long-random-secret-key
// Generate secrets
import crypto from 'crypto';
const secret = crypto.randomBytes(64).toString('hex');
console.log(secret);

2. HttpOnly cookies

// Good: refresh token in HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
  httpOnly: true, // not readable from JavaScript
  secure: true, // HTTPS only
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000,
});
// Bad: refresh token in localStorage
localStorage.setItem('refreshToken', refreshToken); // XSS risk

3. Short expiry

// Access token: 15 minutes
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Refresh token: 7 days
const refreshToken = jwt.sign(payload, secret, { expiresIn: '7d' });

4. Token revocation (denylist)

// Redis-backed denylist
import { createClient } from 'redis';
const redis = createClient();
async function blacklistToken(token) {
  const decoded = jwt.decode(token);
  const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
  await redis.setEx(`blacklist:${token}`, expiresIn, 'true');
}
async function isBlacklisted(token) {
  const result = await redis.get(`blacklist:${token}`);
  return result !== null;
}
async function authenticateToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

  if (await isBlacklisted(token)) {
    return res.status(403).json({ error: 'Token revoked' });
  }

  // Then verify...
}

5. CSRF mitigation

import crypto from 'crypto';
function generateCsrfToken() {
  return crypto.randomBytes(32).toString('hex');
}
app.post('/api/auth/login', async (req, res) => {
  // ... login logic
  const csrfToken = generateCsrfToken();

  res.cookie('csrfToken', csrfToken, {
    httpOnly: false, // must be readable to JS if you mirror in headers
    secure: true,
    sameSite: 'strict',
  });
  res.json({ accessToken, csrfToken });
});
function verifyCsrf(req, res, next) {
  const csrfToken = req.headers['x-csrf-token'];
  const cookieCsrf = req.cookies.csrfToken;
  if (!csrfToken || csrfToken !== cookieCsrf) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }
  next();
}

6. Common mistakes and fixes

Mistake 1: sensitive data in the payload

// Wrong
const token = jwt.sign(
  {
    userId: user.id,
    password: user.password,
    creditCard: user.creditCard,
  },
  secret
);
// Right
const token = jwt.sign(
  {
    userId: user.id,
    email: user.email,
    role: user.role,
  },
  secret
);

Mistake 2: storing tokens in localStorage

// Wrong — XSS risk
localStorage.setItem('accessToken', token);
// Better — keep access token in memory
let accessToken = null;
// Or HttpOnly cookie for refresh token
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,
  secure: true,
});

Mistake 3: hard-coded secrets

// Wrong
const token = jwt.sign(payload, 'my-secret-key');
// Right
const token = jwt.sign(payload, process.env.JWT_SECRET);

Mistake 4: skipping expiry checks

// Wrong — decode does not verify signature or exp
const decoded = jwt.decode(token);
// Right
try {
  const decoded = jwt.verify(token, secret);
} catch (error) {
  if (error.name === 'TokenExpiredError') {
    // handle expiry
  }
}

7. JWT vs session

TopicJWTSession
Where it livesClient (token)Server (session id)
ScalabilityStrong (stateless)Weaker (stateful)
RevocationHard until expiryImmediate server-side
SizeLarger (hundreds of bytes)Small (session id)
MicroservicesFits wellNeeds shared session
MobileFits wellCookies are awkward

When JWT fits well

  • Microservice architectures
  • Mobile apps
  • When horizontal scaling matters
  • Third-party API authentication

When sessions fit well

  • Single-server apps
  • When instant revocation is critical
  • When you want sensitive state only on the server

Summary and checklist

Key takeaways

  • JWT: Header.Payload.Signature string
  • Access token: short TTL (e.g. 15 minutes), sent on API calls
  • Refresh token: longer TTL (e.g. 7 days), used to mint new access tokens
  • Security: HttpOnly cookies, short TTL, secret management
  • Revocation: denylist (e.g. Redis)

Security checklist

  • Load signing secrets from environment variables
  • Store refresh tokens in HttpOnly cookies
  • Keep access token TTL at about 15 minutes or less
  • Never put secrets (passwords, PAN, etc.) in the payload
  • Use HTTPS
  • Add CSRF defenses where cookies are used
  • Implement token denylist for logout


Keywords covered

JWT, authentication, access token, refresh token, security, OAuth

FAQ

Q. Can I store JWTs in localStorage?

A. Not recommended. XSS can exfiltrate them. Prefer in-memory access tokens and HttpOnly cookies for refresh tokens.

Q. Can I invalidate a JWT?

A. Not without extra machinery—JWTs are stateless. Use a denylist (Redis), short TTLs, or refresh-token rotation.

Q. What access token TTL should I use?

A. Often 15 minutes or less. Refresh tokens keep UX smooth while limiting exposure.

Q. How is JWT different from OAuth?

A. JWT is a token format; OAuth is an authorization protocol. OAuth can issue JWTs as access tokens.