Najlepsze praktyki bezpieczeństwa JWT
Tokeny JSON Web są wszędzie — ale większość implementacji ma krytyczne luki bezpieczeństwa. Ten przewodnik omawia wybór algorytmu, wygasanie i rotację tokenów oraz obronę przed głównymi atakami JWT.
Co to jest JWT i jak działa?
JWT to ciąg zakodowany w Base64URL z trzema częściami oddzielonymi kropkami: nagłówek (algorytm + typ), payload (claims) i podpis.
Wybór algorytmu: HS256 vs RS256 vs ES256
Najczęstszy błąd bezpieczeństwa JWT to używanie HS256 w systemie rozproszonym. RS256 i ES256 używają kluczy asymetrycznych.
// Anatomy of a JWT — Header.Payload.Signature
// 1. Header (Base64URL decoded)
const header = {
alg: "RS256", // Algorithm: RS256 (asymmetric) preferred over HS256 (symmetric)
typ: "JWT"
};
// 2. Payload (Base64URL decoded) — the claims
const payload = {
iss: "https://auth.example.com", // Issuer
sub: "user_01HXYZ123", // Subject (user ID)
aud: "https://api.example.com", // Audience (target service)
exp: 1735689600, // Expiry: Unix timestamp (15 min from now)
iat: 1735688700, // Issued at
jti: "8f14e0f9-21af-4c1e-8f26-3b4a5c6d7e8f", // Unique token ID (for revocation)
roles: ["user"], // Custom claims
email: "alice@example.com"
};
// 3. Signature (server-side only)
// RS256: sign(base64url(header) + '.' + base64url(payload), PRIVATE_KEY)
// Verification: verify(token, PUBLIC_KEY) — other services only need the public key
// --- Generating a JWT with jsonwebtoken (Node.js) ---
import jwt from 'jsonwebtoken';
import { readFileSync } from 'fs';
import { randomUUID } from 'crypto';
const privateKey = readFileSync('./private.pem'); // RSA private key
const publicKey = readFileSync('./public.pem'); // RSA public key (shared with all services)
function issueAccessToken(userId: string, roles: string[]): string {
return jwt.sign(
{
sub: userId,
aud: 'https://api.example.com',
roles,
jti: randomUUID(),
},
privateKey,
{
algorithm: 'RS256',
issuer: 'https://auth.example.com',
expiresIn: '15m', // Short-lived access token
}
);
}
function verifyAccessToken(token: string) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // CRITICAL: whitelist algorithms — never allow 'none'
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
}Strategia wygasania i rotacji tokenów
Krótkotrwałe tokeny dostępu (15 minut) w połączeniu z długotrwałymi tokenami odświeżania (7–30 dni) to standard branżowy.
// Refresh Token Pattern — Stateless Access + Stateful Refresh
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
// ---- Token issuance ----
async function issueTokenPair(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, jti: randomUUID() },
process.env.JWT_PRIVATE_KEY!,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Refresh token: random bytes stored in DB — NOT a JWT
const refreshToken = randomBytes(64).toString('hex');
const refreshExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
// Store hashed refresh token in DB (never store plaintext)
await db.refreshTokens.create({
tokenHash: sha256(refreshToken),
userId,
expiresAt: refreshExpiry,
createdAt: new Date(),
});
return { accessToken, refreshToken, refreshExpiry };
}
// ---- Token refresh endpoint ----
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies; // httpOnly cookie
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
const stored = await db.refreshTokens.findOne({
tokenHash: sha256(refreshToken),
expiresAt: { >: new Date() }, // Not expired
revokedAt: null, // Not revoked
});
if (!stored) return res.status(401).json({ error: 'Invalid or expired refresh token' });
// Rotate: invalidate old, issue new pair (refresh token rotation)
await db.refreshTokens.update(
{ id: stored.id },
{ revokedAt: new Date() } // Revoke old refresh token
);
const newPair = await issueTokenPair(stored.userId);
// Set new refresh token as httpOnly cookie
res.cookie('refreshToken', newPair.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: newPair.refreshExpiry,
path: '/auth/refresh', // Scope cookie to refresh endpoint only
});
return res.json({ accessToken: newPair.accessToken });
});
// ---- Logout: revoke refresh token ----
app.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
await db.refreshTokens.update(
{ tokenHash: sha256(refreshToken) },
{ revokedAt: new Date() }
);
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
return res.json({ success: true });
});Typowe ataki JWT i obrona
Najgroźniejsze luki JWT to: zamieszanie algorytmu, zamieszanie RS256→HS256, słabe sekrety i brakująca walidacja exp.
// JWT Attack Defenses — Code Patterns
// ---- ATTACK 1: Algorithm confusion ("none" bypass) ----
// VULNERABLE: never use verify() without specifying allowed algorithms
const VULNERABLE = jwt.verify(token, secret); // attacker can set alg: "none"
// SAFE: always whitelist algorithms
const SAFE = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Never include 'none', 'HS256' if you're using RS256
});
// ---- ATTACK 2: RS256 > HS256 confusion ----
// Attacker changes alg from RS256 to HS256 and signs with the PUBLIC key as the secret
// Defense: always specify algorithm on verify — never infer from the token header
// VULNERABLE:
const header = JSON.parse(atob(token.split('.')[0]));
jwt.verify(token, publicKey, { algorithms: [header.alg] }); // attacker controls this!
// SAFE:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // hardcoded
// ---- ATTACK 3: Weak HS256 secrets (brute-force) ----
// VULNERABLE: short or predictable secrets
const BAD_SECRET = 'secret';
const BAD_SECRET2 = 'mysupersecretkey';
// SAFE: 256-bit cryptographically random secret
import { randomBytes } from 'crypto';
const GOOD_SECRET = randomBytes(32).toString('hex'); // 256-bit
// Store in environment variable, rotate annually
// ---- ATTACK 4: Missing expiry validation ----
// Some older libraries skip exp check by default
// SAFE: always validate exp (jsonwebtoken does this by default)
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 30, // Allow 30 seconds clock skew max
ignoreExpiration: false, // NEVER set this to true in production
});
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// ---- Token blacklist (for early revocation via jti) ----
import { createClient } from 'redis';
const redis = createClient();
async function revokeToken(jti: string, exp: number) {
const ttl = exp - Math.floor(Date.now() / 1000); // seconds until natural expiry
if (ttl > 0) {
await redis.set(`jwt:revoked:${jti}`, '1', { EX: ttl });
}
}
async function isTokenRevoked(jti: string): Promise<boolean> {
return (await redis.get(`jwt:revoked:${jti}`)) !== null;
}
// Middleware: check blacklist
app.use(async (req, res, next) => {
const decoded = jwt.verify(req.headers.authorization?.split(' ')[1] ?? '', publicKey, {
algorithms: ['RS256'],
});
if (await isTokenRevoked((decoded as jwt.JwtPayload).jti ?? '')) {
return res.status(401).json({ error: 'Token revoked' });
}
next();
});Bezpieczne przechowywanie tokenów
Tokeny dostępu: przechowuj w pamięci. Tokeny odświeżania: przechowuj w plikach cookie httpOnly, Secure, SameSite=Strict.
Często zadawane pytania
Czy powinienem używać JWT czy nieprzezroczystych tokenów sesji?
Używaj nieprzezroczystych tokenów sesji dla aplikacji renderowanych po stronie serwera. Używaj JWT dla mikroserwisów lub API.
Jak odwołać JWT przed wygaśnięciem?
Rozwiązania: krótkie wygasanie (15 min), czarna lista tokenów w Redis lub wersja tokenu w rekordzie użytkownika.
Jakie claims powinien zawierać każdy JWT?
Wymagane: iss, sub, aud, exp, iat, jti. Zawsze waliduj iss i aud w serwisie odbierającym.
Jak długie powinny być sekrety JWT i klucze prywatne?
Sekrety HS256: minimum 256 bitów. RS256: minimum 2048-bitowy klucz RSA. ES256: 256-bitowy klucz krzywej eliptycznej.