JWT Authentication: Complete Guide for Web Developers (2026)

Published March 2026 • 10 min read

What is JWT Authentication?

JSON Web Token (JWT, pronounced "jot") is a compact, self-contained standard for transmitting authentication and authorization information between parties as a digitally signed token. Once a user logs in, the server issues a JWT containing claims about that user. The client stores the token and sends it with every subsequent request. The server verifies the token's signature to confirm authenticity — no database lookup required.

JWTs are defined by RFC 7519 and are the dominant authentication mechanism for REST APIs, single-page applications, and microservices architectures. They are stateless by design: the server does not need to store session data, making horizontal scaling straightforward.

How JWT Works

The full JWT authentication flow works like this:

  1. Login: The user submits their credentials (username + password) to the server's login endpoint.
  2. Token issuance: The server validates the credentials against the database. On success, it signs a JWT containing the user's ID, roles, and an expiry timestamp, then returns the token to the client.
  3. Token storage: The client stores the token (in memory, localStorage, or an httpOnly cookie — more on this later).
  4. Authenticated requests: For every subsequent API request, the client includes the JWT in the Authorization header: Authorization: Bearer <token>.
  5. Verification: The server extracts the token from the header, verifies the signature using its secret key, checks the expiry, and reads the claims — all without a database query.
  6. Response: If the token is valid, the server processes the request. If not (expired, tampered, missing), it returns a 401 Unauthorized.

JWT Structure Explained

A JWT consists of three Base64URL-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTYiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDkzMDAwMDAsImV4cCI6MTcwOTMwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The three parts are:

1. Header

{
  "alg": "HS256",   // Signing algorithm (HS256, RS256, ES256)
  "typ": "JWT"      // Token type
}

2. Payload (Claims)

{
  "userId": "123456",
  "email": "user@example.com",
  "role": "admin",
  "iat": 1709300000,   // Issued at (Unix timestamp)
  "exp": 1709303600    // Expiry (1 hour from issuance)
}

Standard claim names: iss (issuer), sub (subject), aud (audience), exp (expiry), iat (issued at), jti (JWT ID for revocation). The payload is Base64URL-encoded, not encrypted — anyone can decode it. Never store passwords, credit card numbers, or other sensitive data in the payload.

3. Signature

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secretKey
)

The signature proves the token was issued by your server and has not been tampered with. If any character in the header or payload changes, the signature verification fails.

Implementing JWT in Node.js

Install the jsonwebtoken package:

npm install jsonwebtoken bcryptjs express

Create a complete login and protected route example:

// auth.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const express = require('express');
const router = express.Router();

const JWT_SECRET = process.env.JWT_SECRET; // Must be a long, random string
const JWT_EXPIRES_IN = '1h';               // Keep access tokens short-lived

// Login route — issue a JWT on successful authentication
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // 1. Look up the user in your database
  const user = await User.findOne({ email });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 2. Compare password with stored hash
  const validPassword = await bcrypt.compare(password, user.passwordHash);
  if (!validPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 3. Sign the JWT — include only non-sensitive claims
  const token = jwt.sign(
    { userId: user._id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' }
  );

  res.json({ token, expiresIn: 3600 });
});

// Middleware to verify JWT on protected routes
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 {
    // Explicitly specify the algorithm to prevent algorithm confusion attacks
    const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
    req.user = decoded;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

// Protected route example
router.get('/profile', authenticateToken, (req, res) => {
  res.json({ userId: req.user.userId, email: req.user.email });
});

module.exports = { router, authenticateToken };

Storing JWT in React

Where you store the JWT in the browser significantly affects your application's security posture. The two main options are localStorage and httpOnly cookies:

Property localStorage httpOnly Cookie
XSS vulnerability High — any script can read it Low — inaccessible to JavaScript
CSRF vulnerability None Yes — mitigate with SameSite + CSRF token
Sent automatically No — must add to header manually Yes — browser sends with every request
Accessible from JS Yes No (httpOnly flag)
Recommended for Low-risk apps, public data Production apps with user data

For production applications, prefer httpOnly cookies. Your server sets the cookie on login and the browser sends it automatically, without JavaScript ever touching the token value.

If you do use localStorage (e.g., for simplicity during development), store the token in memory where possible and implement a robust Content Security Policy to mitigate XSS risk:

// React — sending JWT from localStorage
async function fetchProfile() {
  const token = localStorage.getItem('authToken');

  const response = await fetch('/api/profile', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });

  if (response.status === 401) {
    // Token expired — redirect to login or refresh
    localStorage.removeItem('authToken');
    window.location.href = '/login';
  }

  return response.json();
}

Refresh Token Strategy

Short-lived access tokens (15 minutes to 1 hour) minimize the window of token theft, but forcing users to log in every hour is terrible UX. The solution is a refresh token pair:

  • Access token: Short-lived (15 min – 1 hour), sent with every API request in the Authorization header. Stored in memory or localStorage.
  • Refresh token: Long-lived (7 – 30 days), used only to obtain new access tokens. Always stored in an httpOnly, Secure, SameSite=Strict cookie. Should be rotated on every use.
// Server: issue both tokens on login
router.post('/login', async (req, res) => {
  // ... validate credentials ...

  const accessToken = jwt.sign(
    { userId: user._id, role: user.role },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { userId: user._id },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '30d' }
  );

  // Store refresh token hash in database for revocation support
  await RefreshToken.create({ userId: user._id, token: hashToken(refreshToken) });

  // Send refresh token as httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in ms
  });

  res.json({ accessToken });
});

// Server: refresh endpoint
router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  try {
    const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    // Optionally: check token hash exists in DB (enables revocation)

    const newAccessToken = jwt.sign(
      { userId: decoded.userId },
      process.env.JWT_ACCESS_SECRET,
      { expiresIn: '15m' }
    );

    res.json({ accessToken: newAccessToken });
  } catch {
    res.status(403).json({ error: 'Invalid refresh token' });
  }
});

JWT Security Best Practices

  • Keep access tokens short-lived: 15 minutes to 1 hour. Stolen tokens are useless after expiry.
  • Use strong secrets: Your JWT_SECRET should be at least 256 bits of random data. Generate it with openssl rand -hex 32. Never hardcode it.
  • Always specify the algorithm: Pass { algorithms: ['HS256'] } to jwt.verify(). This prevents the algorithm confusion (none) attack where an attacker sets "alg": "none" in the header.
  • HTTPS only in production: JWTs in transit are only as secure as your transport layer. Always use TLS in production.
  • Store refresh tokens in httpOnly cookies: Not in localStorage or sessionStorage.
  • Implement token revocation: For logout and security events, maintain a short-lived blocklist (Redis works well) or use rotating refresh tokens with database tracking.
  • Validate all claims: Check exp (expiry), iss (issuer), and aud (audience) on every request. The jsonwebtoken library does this automatically when you pass the right options.

Common JWT Mistakes

Storing JWTs in localStorage on high-risk apps

Any XSS vulnerability — even a third-party script you loaded — can read localStorage and exfiltrate all tokens. For applications handling payments, personal data, or admin actions, use httpOnly cookies exclusively.

No expiry on tokens

Tokens without an exp claim are valid forever. A leaked token becomes a permanent backdoor. Always set expiresIn when signing.

The algorithm none attack

Early JWT libraries accepted "alg": "none" in the header, effectively accepting unsigned tokens as valid. Always pass the algorithms option to jwt.verify() to restrict which algorithms are acceptable:

// Always do this — never allow the "none" algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });

Sensitive data in the payload

The JWT payload is Base64URL-encoded, not encrypted. Any party with the token can decode and read the payload without knowing the secret. Never include passwords, SSNs, credit card numbers, or internal system details in JWT claims.

Using the same secret for access and refresh tokens

If you use the same secret for both token types, a compromised access token could potentially be used as a refresh token. Use separate secrets for each token type stored in separate environment variables.

Decode Your JWT Tokens

Need to inspect a JWT to check its claims, expiry, or structure? Paste it into the devbit.dev JWT Decoder to instantly decode the header and payload, check the expiry time, and validate the token format — all in your browser with no data sent to any server.

Decode & Inspect Any JWT Instantly

Paste a JWT token to decode its header, payload, and claims. Checks expiry, algorithm, and structure. 100% client-side — your token never leaves your browser.

Open JWT Decoder →

Related Developer Tools