[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 timeexp(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)
| Pattern | Purpose |
|---|---|
Validate aud | Ensure the token is for this API |
iss + JWKS | Verify with the right IdP keys and rotate keys safely |
jti + denylist | Logout and revocation without guessing expiry |
| Separate secrets | Different 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
| Topic | JWT | Session |
|---|---|---|
| Where it lives | Client (token) | Server (session id) |
| Scalability | Strong (stateless) | Weaker (stateful) |
| Revocation | Hard until expiry | Immediate server-side |
| Size | Larger (hundreds of bytes) | Small (session id) |
| Microservices | Fits well | Needs shared session |
| Mobile | Fits well | Cookies 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.Signaturestring - 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
Related reading
- Web security guide (OWASP, XSS, CSRF) (Korean)
- Next.js 15 guide (App Router, Server Actions)
- Docker Compose in practice
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.