Secure JWT Tokens in Next.js – A Practical Guide for Engineers

Learn how to build a robust JWT system in Next.js and Node.js, protecting tokens from theft, CSRF and XSS.

Bezpieczne tokeny JWT w Next.js – praktyczny przewodnik dla inżynierów

Modern web applications increasingly rely on JWT (JSON Web Token) tokens as the core authentication mechanism. In a Next.js environment, which combines server‑side rendering (SSR) and client‑side rendering (CSR), proper session management requires a thoughtful architecture. This article presents a threat model, describes how the mechanism works, shows a contrast between vulnerable and hardened code, and provides a mitigation checklist aligned with OWASP, CWE, and the least‑privilege principle.

Threat model – what could go wrong?

The primary attack vectors against JWT‑based systems are:

  • Token theft via XSS (a script injected in the browser reads the token from local storage or an unprotected cookie);
  • CSRF attack, when the browser automatically sends the cookie containing the token in a request to the API;
  • Replay attack – a captured token is reused before it expires;
  • Lack of key rotation – in case of a leak, you can instantly invalidate all tokens.

This model can be visualized as three layers: token storage, its verification, and lifecycle management. Each layer requires separate safeguards.

How does the JWT mechanism work in Next.js?

A typical flow looks like this:

  1. The user logs in, the server (Node.js) generates two tokens – accessToken (short‑lived) and refreshToken (longer‑lived).
  2. The tokens are sent to the browser in HttpOnly and Secure cookies.
  3. During an API request the browser automatically includes the accessToken. Middleware in Next.js verifies the signature and expiration date.
  4. When the accessToken expires, the application calls a refresh endpoint, providing the refreshToken. The server returns a new accessToken and, optionally, a new refreshToken.

The key is that tokens must not be accessible to JavaScript (hence HttpOnly) and that every endpoint must verify the signature using the current key.

Example: vulnerable vs. hardened code

Below we compare two snippets – first a simple but unsafe way of storing the token in localStorage, then the recommended implementation using HttpOnly cookies and Next.js middleware.

// VULNERABLE – storing in localStorage (XSS‑prone)
function login(credentials) {
  fetch('/api/auth/login', {method: 'POST', body: JSON.stringify(credentials)})
    .then(r => r.json())
    .then(data => {
      localStorage.setItem('accessToken', data.accessToken);
      localStorage.setItem('refreshToken', data.refreshToken);
    });
}

// HARDENED – setting HttpOnly cookies on the server side
// pages/api/auth/login.js
import { sign } from 'jsonwebtoken';
export default async function handler(req, res) {
  const { username, password } = req.body;
  // ... verify credentials ...
  const accessToken = sign({ sub: username }, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' });
  const refreshToken = sign({ sub: username }, process.env.JWT_REFRESH_SECRET, { expiresIn: '30d' });
  res.setHeader('Set-Cookie', [
    `accessToken=${accessToken}; HttpOnly; Secure; SameSite=Strict; Path=/api; Max-Age=900`,
    `refreshToken=${refreshToken}; HttpOnly; Secure; SameSite=Strict; Path=/api/auth/refresh; Max-Age=2592000`
  ]);
  res.status(200).json({ message: 'Logged in' });
}

In the hardened version the tokens never reach JavaScript, and the SameSite=Strict flags reduce CSRF risk. Additionally, we limit the Path so that cookies are sent only to dedicated endpoints.

Mitigation checklists – what to do to meet OWASP and CWE

  • Storage: use HttpOnly + Secure cookies; avoid localStorage and sessionStorage for tokens.
  • Domain and path restriction: set Domain and Path so the cookie is available only to the API.
  • SameSite: choose Strict or Lax based on your needs to protect against CSRF.
  • Validation: verify the signature, expiration date, and claims (iss, aud) on the server side.
  • Key rotation: regularly change JWT secrets; keep the previous key for a short period to allow verification of existing tokens.
  • Token refresh: use short‑lived accessToken and longer‑lived refreshToken; after a successful refresh, invalidate the previous refresh token.
  • Rate limiting and monitoring of failed verifications – reduces brute‑force and token‑replay risk.

Implementing the above points addresses CWE‑287 (Improper Authentication) and CWE‑352 (Cross‑Site Request Forgery).

JWT Key Rotation Strategy

Keys used to sign tokens should have a defined lifespan (e.g., 30 days). The rotation process includes:

  1. Generating a new key and storing it in a secure vault (e.g., AWS KMS, HashiCorp Vault).
  2. Adding the new key to the list of “accepted” keys in the application configuration, keeping the previous key as a “fallback”.
  3. After the transition period expires (e.g., 24 h), removing the old key – all tokens signed with the older key will no longer be accepted.

In Next.js this can be achieved with a helper function:

// utils/jwt.js
import jwt from 'jsonwebtoken';
const keys = {
  current: process.env.JWT_ACCESS_SECRET,
  previous: process.env.JWT_ACCESS_SECRET_OLD,
};
export function verify(token) {
  try {
    return jwt.verify(token, keys.current);
  } catch (e) {
    // fallback to previous key
    return jwt.verify(token, keys.previous);
  }
}

This approach satisfies the OWASP “Key Management” requirement and minimizes the risk of session interruption during rotation.

Common Mistakes and Trade‑offs

Even experienced teams make mistakes. The most frequent are:

  • Setting an excessively long access‑token lifetime – increases the attack surface in case of theft.
  • Not rotating the refresh token – allows unlimited use of a stolen token.
  • Using a single key for both access and refresh tokens – makes rotation harder and amplifies the impact of a leak.
  • Improper SameSite flags – with None tokens are vulnerable to CSRF in a cross‑site context.
  • Exposing tokens in logs – especially in CI/CD environments.

Compromise solutions, such as a “sliding expiration” for the refresh token, can help, but the security and usability impact must always be evaluated.

The best defense is not only technology but conscious session design from the very beginning.

Practical Implementation Checklist

  • ✅ Use HttpOnly, Secure and SameSite=Strict when setting JWT cookies.
  • ✅ Restrict the cookie Path to API endpoints.
  • ✅ Implement middleware in Next.js that verifies the token on every request.
  • ✅ Create a token‑refresh endpoint with refresh‑token rotation.
  • ✅ Rotate keys every 30‑60 days, keeping the previous key as a fallback.
  • ✅ Monitor failed verifications and apply rate limiting.
  • ✅ Regularly review code for XSS and remove all unsafe data injections.

By completing this list, the engineering team builds a solid session‑security foundation aligned with OWASP Top 10 standards and CWE‑798 (Use of Hard‑coded Credentials).

In summary, secure JWTs in Next.js require a thoughtful storage strategy, regular key rotation, and strict control over the token lifecycle. By implementing the described practices, you will reduce the risk of token theft, CSRF and XSS attacks, and ensure compliance with top industry security standards. If you need assistance with a security audit or implementing such a system in your organization, contact Coderia.it – we’ll help you build a solution that meets the highest security and performance requirements.

Let’s start

Got a project in mind?

Describe it in a few sentences. I reply within 24 hours with a free quote and a proposed stack.