Authentication with JWT in Express.js

Authentication with JWT in Express.js

I shipped JWT auth with tokens that never expired, stored in localStorage, to production. This is the post-mortem. Covers JWT structure, why your token strategy probably has holes, access and refresh tokens done right, password hashing with bcrypt, and the storage debate that never ends.

I shipped JWT auth with tokens that never expired. In localStorage. In production. For three months.

Nobody noticed until a security audit flagged it. By that point we had thousands of active tokens floating around that would work forever, sitting in a storage mechanism that any XSS vulnerability could drain in one line of JavaScript. Cleaning it up meant force-logging out every user and rewriting half the auth flow over a weekend.

This post is what I wish I'd read before writing that first implementation. Every section maps to a mistake I actually made.

What a JWT Actually Is (and Isn't)

A JSON Web Token is three chunks separated by dots: header.payload.signature. Each chunk is Base64Url-encoded. It looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTYiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDkwMDAwMDAsImV4cCI6MTcwOTAwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The header says what algorithm was used to sign it:

{
  "alg": "HS256",
  "typ": "JWT"
}

The payload carries your claims -- data about the user. There are registered claims like exp (expiration) and iat (issued at), and private claims you define yourself like userId or role:

{
  "userId": "123456",
  "email": "[email protected]",
  "role": "admin",
  "iat": 1709000000,
  "exp": 1709003600
}

The signature is a hash of the header + payload + your secret key. If anyone tampers with the payload, the signature won't match and the server rejects the token.

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

Here's what tripped me up early on: the payload is encoded, not encrypted. Anyone can decode it and read the contents. Base64 is not encryption. Don't put passwords, credit card numbers, or anything sensitive in a JWT. The signature guarantees nobody changed the data. It does not hide the data.

Mistake #1: Storing Passwords Wrong

My first version stored passwords with a simple SHA-256 hash. No salt. That's barely better than plain text -- rainbow table attacks make short work of unsalted hashes.

The fix is bcrypt. It's designed specifically for passwords. It handles salting automatically and it's intentionally slow, which makes brute-force attacks impractical.

First, the project setup:

mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y
npm install express jsonwebtoken bcrypt cookie-parser dotenv

Then the password functions:

const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12;

// Hash a password during registration
async function hashPassword(plainTextPassword) {
  const salt = await bcrypt.genSalt(SALT_ROUNDS);
  const hashedPassword = await bcrypt.hash(plainTextPassword, salt);
  return hashedPassword;
}

// Compare password during login
async function comparePassword(plainTextPassword, hashedPassword) {
  const isMatch = await bcrypt.compare(plainTextPassword, hashedPassword);
  return isMatch;
}

The SALT_ROUNDS value of 12 means bcrypt runs 2^12 (4,096) iterations. Higher is more secure but slower. On our production hardware, 12 takes about 250ms per hash, which is fine for login but would kill performance if you put it in a hot path. Benchmark it on your own servers.

Environment variables go in .env:

ACCESS_TOKEN_SECRET=your_super_secret_access_key_here_make_it_long_and_random
REFRESH_TOKEN_SECRET=a_completely_different_secret_for_refresh_tokens
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
PORT=3000

Two different secrets -- one for access tokens, one for refresh tokens. If one leaks, the other still works. Generate them with require('crypto').randomBytes(64).toString('hex'), not by mashing your keyboard.

Mistake #2: No Token Expiration (and No Refresh Flow)

My original implementation issued one token at login. No expiration. No refresh mechanism. That meant if a token got stolen, it worked forever. There was no way to invalidate it short of changing the signing secret, which would log out every user at once.

The fix: two tokens with different lifespans. Access tokens are short-lived (15 minutes) and sent with every API request. Refresh tokens are long-lived (7 days) and used only to get new access tokens. If an access token gets stolen, the damage window is 15 minutes, not infinity.

const jwt = require('jsonwebtoken');
require('dotenv').config();

function generateAccessToken(user) {
  return jwt.sign(
    {
      userId: user.id,
      email: user.email,
      role: user.role
    },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
  );
}

function generateRefreshToken(user) {
  return jwt.sign(
    { userId: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
  );
}

Now the registration and login routes:

const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
require('dotenv').config();

const app = express();
app.use(express.json());
app.use(cookieParser());

// In-memory store (use a database in production)
const users = [];
const refreshTokens = new Set();

// Registration
app.post('/api/auth/register', async (req, res) => {
  try {
    const { name, email, password } = req.body;

    // Check if user already exists
    const existingUser = users.find(u => u.email === email);
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered' });
    }

    // Hash the password
    const hashedPassword = await bcrypt.hash(password, 12);

    // Create user
    const user = {
      id: Date.now().toString(),
      name,
      email,
      password: hashedPassword,
      role: 'user'
    };
    users.push(user);

    res.status(201).json({ message: 'User registered successfully' });
  } catch (error) {
    res.status(500).json({ error: 'Registration failed' });
  }
});

// Login
app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    // Find user
    const user = users.find(u => u.email === email);
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Verify password
    const isValidPassword = await bcrypt.compare(password, user.password);
    if (!isValidPassword) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Generate tokens
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    // Store refresh token
    refreshTokens.add(refreshToken);

    // Set refresh token in httpOnly cookie
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
    });

    // Send access token in response body
    res.json({ accessToken, user: { id: user.id, name: user.name, email: user.email } });
  } catch (error) {
    res.status(500).json({ error: 'Login failed' });
  }
});

One detail worth noting: wrong email and wrong password return the same error message. This is intentional. Different error messages for "user not found" vs "wrong password" let attackers figure out which email addresses are registered. It's called user enumeration and it's an easy vector to close.

Mistake #3: Auth Logic Scattered Everywhere

In my first version, every route had its own token-checking code. Copy-pasted, slightly different each time. Some routes checked expiration, others didn't. Some returned 401, others returned 403. It was a mess.

The fix is a single authentication middleware that sits in front of 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 {
    const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

And a separate authorization middleware for role checks. Separate because not every authenticated route needs role-based access:

function authorizeRoles(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user || !allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Usage: protect routes
app.get('/api/profile', authenticateToken, (req, res) => {
  const user = users.find(u => u.id === req.user.userId);
  res.json({ user: { id: user.id, name: user.name, email: user.email } });
});

app.get('/api/admin/dashboard', authenticateToken, authorizeRoles('admin'), (req, res) => {
  res.json({ message: 'Welcome to the admin dashboard' });
});

Auth in one function. Authorization in another. Compose them on each route. Now there's exactly one place to fix bugs and one place to add logging.

Mistake #4: No Token Rotation

Even after adding refresh tokens, I initially reused the same refresh token until it expired. That meant if someone stole a refresh token, they had a week-long window to mint as many access tokens as they wanted.

The fix is refresh token rotation. Every time a refresh token is used, it gets invalidated and replaced with a new one. If an attacker tries to use a stolen (already-consumed) token, the server rejects it.

app.post('/api/auth/refresh', (req, res) => {
  const { refreshToken } = req.cookies;

  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }

  // Check if token exists in our store
  if (!refreshTokens.has(refreshToken)) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }

  try {
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);

    // Rotate the refresh token: invalidate old, issue new
    refreshTokens.delete(refreshToken);

    const user = users.find(u => u.id === decoded.userId);
    if (!user) {
      return res.status(403).json({ error: 'User not found' });
    }

    const newAccessToken = generateAccessToken(user);
    const newRefreshToken = generateRefreshToken(user);

    refreshTokens.add(newRefreshToken);

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

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

In a real production system, you'd store refresh tokens in a database with a family ID. If a token from the same family gets reused, you know it was stolen and you can invalidate the entire family -- logging out the attacker and the real user, who then re-authenticates. This is documented in the OAuth 2.0 spec and it's worth implementing if you're handling anything sensitive.

Logout rounds it out:

app.post('/api/auth/logout', (req, res) => {
  const { refreshToken } = req.cookies;

  if (refreshToken) {
    refreshTokens.delete(refreshToken);
  }

  res.clearCookie('refreshToken', {
    httpOnly: true,
    secure: true,
    sameSite: 'strict'
  });

  res.json({ message: 'Logged out successfully' });
});

This was my worst mistake, and it's the one I see repeated most often in tutorials. I stored JWTs in localStorage. It's easy to do, it's in half the blog posts out there, and it's wrong.

localStorage (and sessionStorage) is accessible to any JavaScript running on your page. If your app has a single XSS vulnerability -- one unsanitized input, one compromised npm package -- an attacker can grab every token in storage with one line of code: localStorage.getItem('token'). Game over.

There are two legitimate approaches, and the best option is to combine them:

Authorization Header (Bearer Token): Store the access token in a JavaScript variable -- not localStorage, just a variable in memory. Send it via Authorization: Bearer <token>. It disappears on page refresh, which is fine because you have a refresh token to get a new one. XSS can still access in-memory variables while the page is open, but the window is much smaller and the token expires in 15 minutes anyway.

HTTP-Only Cookies: Store the refresh token in a cookie with httpOnly: true. JavaScript cannot read httpOnly cookies. Period. This makes the refresh token immune to XSS. The tradeoff is CSRF vulnerability, but sameSite: 'strict' handles that.

The recommended hybrid:

  • Access token in memory, sent via Authorization header. Short-lived. If compromised, limited damage.
  • Refresh token in an httpOnly, secure, sameSite cookie. Long-lived but invisible to JavaScript.
Never store tokens in localStorage or sessionStorage. I know it's in every tutorial. It's still wrong. Any JavaScript running on your page can read web storage, including malicious scripts injected through XSS.

Fixing the Client Side

Once you have the right storage strategy, you need the client to handle token expiry without constantly redirecting to the login page. An Axios interceptor does this well:

const axios = require('axios');

const api = axios.create({
  baseURL: 'http://localhost:3000/api',
  withCredentials: true // Required for cookies
});

let accessToken = null;

// Request interceptor: attach access token
api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// Response interceptor: handle token expiry
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401
        && error.response?.data?.code === 'TOKEN_EXPIRED'
        && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const { data } = await api.post('/auth/refresh');
        accessToken = data.accessToken;
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        accessToken = null;
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

The _retry flag prevents infinite loops. Without it, a failed refresh triggers another 401, which triggers another refresh, forever. I learned this one the hard way too -- our logs filled up with thousands of refresh requests per second from a single client before we caught it.

What I'd Do Differently Today

If I were starting over, here's what I'd get right from day one.

HTTPS everywhere. Tokens over plain HTTP can be intercepted by anyone on the network. Set secure: true on cookies so they only travel over HTTPS. This isn't optional.

Keep payloads small. Every token gets sent with every request. I once saw someone put the user's entire profile, including their bio, into the JWT payload. Don't. Include user ID, role, and maybe email. That's it.

Build a token blacklist for emergencies. Yes, this goes against the "stateless" nature of JWT. I don't care. When a user changes their password or an admin disables an account, you need to kill active tokens immediately. Use Redis -- it's fast and the entries auto-expire:

const redis = require('redis');
const client = redis.createClient();

async function blacklistToken(token, expiresIn) {
  await client.set(`bl_${token}`, 'revoked', { EX: expiresIn });
}

async function isTokenBlacklisted(token) {
  const result = await client.get(`bl_${token}`);
  return result !== null;
}

// Updated middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  try {
    const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);

    // Check blacklist
    isTokenBlacklisted(token).then(blacklisted => {
      if (blacklisted) {
        return res.status(401).json({ error: 'Token revoked' });
      }
      req.user = decoded;
      next();
    });
  } catch (error) {
    return res.status(403).json({ error: 'Invalid token' });
  }
}

Other things that matter: generate secrets with at least 256 bits of randomness. Rate-limit your auth endpoints -- someone will try to brute-force them. Log every authentication event so you can audit later. And if you have multiple services that need to verify tokens, use RS256 (asymmetric keys) instead of HS256 -- you share the public key for verification without exposing the private signing key.

Auth is one of those things where "good enough" isn't. Get it right or use a service.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!