Secure Authentication with JWTs & Rotating Refresh Tokens (TypeScript + Express + Vanilla JS)

Authentication is a fundamental part of web applications, and handling it securely is crucial. Many developers store both accessToken and refreshToken in localStorage, but this exposes the application to XSS attacks. A better approach is: ✅ Store the refreshToken in an HTTP-only cookie (prevents XSS) ✅ Store the accessToken in localStorage or memory (easy access for API calls) ✅ Use rotating refresh tokens (each refresh invalidates the previous one) In this guide, we'll build a simple authentication system using Express (TypeScript) for the backend and Vanilla JavaScript for the frontend. Overview of JWT Authentication Flow Before diving into code, let's understand the authentication flow: User Login: The user provides credentials, and the server verifies them. Token Issuance: Upon successful authentication, the server issues two tokens: A short-lived access token (e.g., 15 minutes) A long-lived refresh token (e.g., 7 days) Token Storage: Access token is stored in localStorage or memory Refresh token is stored in an HTTP-only cookie API Authorization: The client includes the access token in the Authorization header for API requests Token Expiration: When the access token expires, the client uses the refresh token to get a new access token Token Rotation: Each refresh invalidates the previous refresh token, enhancing security Building the Backend 1. Imports & Basic Setup We begin by importing the necessary packages, configuring our app to use JSON, cookies, and CORS, and initializing environment variables with dotenv: import express, { NextFunction, Request, Response } from "express"; import jwt from "jsonwebtoken"; import cookieParser from "cookie-parser"; import cors from "cors"; import dotenv from "dotenv"; import { randomUUID as uuidv4 } from "crypto"; // Load environment variables dotenv.config(); // Create an Express app const app = express(); // Enable JSON body parsing and cookies app.use(express.json()); app.use(cookieParser()); // CORS setup to allow cross-domain requests (with cookies) app.use( cors({ credentials: true, origin: ["http://localhost:5500", "http://127.0.0.1:5500"], }) ); Notice the credentials: true and specifying the frontend origin array ensure that cookies are sent with requests to our server from those domains. 2. In-Memory “Database” For simplicity, we’ll store our user and token data in memory. In production, replace this with a real database (like PostgreSQL, MongoDB, etc.): const db = { // Our only user: "admin" / "password" users: [{ id: 1, username: "admin", password: "password" }], // Mapping of user IDs to their valid refresh tokens // e.g. refreshTokens[1] = "" refreshTokens: {} as Record, } as const; Key points: db.refreshTokens holds each user’s valid refresh token. If a token isn’t in here, it’s invalid. We mark everything as readonly (as const) just for clarity in our toy example. Secrets & Utility Functions We read our JWT secrets from environment variables, then define two helper functions to generate tokens: const ACCESS_SECRET = process.env.ACCESS_SECRET!; const REFRESH_SECRET = process.env.REFRESH_SECRET!; // A simple lookup for the latest refresh token per user let refreshTokens: Record = {}; // Generate a short-lived access token (5s for demo; ~15min for real apps) const generateAccessToken = (user: any) => jwt.sign({ id: user.id }, ACCESS_SECRET, { expiresIn: "5s" }); // Generate a refresh token with a unique tokenId for rotation const generateRefreshToken = (user: any) => { const tokenId = uuidv4(); // unique ID for the refresh token const token = jwt.sign({ id: user.id, tokenId }, REFRESH_SECRET, { expiresIn: "7d", }); refreshTokens[user.id] = token; return token; }; Key Points Access Token (generateAccessToken): Short expiration (5 seconds here just for demonstration). Should contain minimal user info (here, only id). Refresh Token (generateRefreshToken): Longer expiration (7 days). Includes a tokenIdto facilitate more advanced revocation strategies if needed. Stored in refreshTokens[user.id] so only the latest token is valid. 4. Login Route When a user logs in, we generate an access token and a refresh token. The access token is returned in the JSON response, and the refresh token is stored in an HTTP-only cookie: app.post("/auth/login", (req, res) => { const { username, password } = req.body; // Basic credential check (plaintext for demo) const user = db.users.find( (u) => u.username === username && u.password === password ); if (!user) { res.status(401).json({ message: "Invalid credentials" }); return; } // Create tokens const accessToken = generateAccessToken(user); const refreshToken = generateRefreshToken(user); // Also store it in db.refreshTokens to track validity db.refreshTokens[user.id] = refreshToken; // Send refresh toke

Mar 14, 2025 - 07:52
 0
Secure Authentication with JWTs & Rotating Refresh Tokens (TypeScript + Express + Vanilla JS)

Authentication is a fundamental part of web applications, and handling it securely is crucial. Many developers store both accessToken and refreshToken in localStorage, but this exposes the application to XSS attacks.

A better approach is:
✅ Store the refreshToken in an HTTP-only cookie (prevents XSS)
✅ Store the accessToken in localStorage or memory (easy access for API calls)
✅ Use rotating refresh tokens (each refresh invalidates the previous one)

In this guide, we'll build a simple authentication system using Express (TypeScript) for the backend and Vanilla JavaScript for the frontend.

Overview of JWT Authentication Flow

Before diving into code, let's understand the authentication flow:

  1. User Login: The user provides credentials, and the server verifies them.
  2. Token Issuance: Upon successful authentication, the server issues two tokens:
    • A short-lived access token (e.g., 15 minutes)
    • A long-lived refresh token (e.g., 7 days)
  3. Token Storage:
    • Access token is stored in localStorage or memory
    • Refresh token is stored in an HTTP-only cookie
  4. API Authorization: The client includes the access token in the Authorization header for API requests
  5. Token Expiration: When the access token expires, the client uses the refresh token to get a new access token
  6. Token Rotation: Each refresh invalidates the previous refresh token, enhancing security

Building the Backend

1. Imports & Basic Setup

We begin by importing the necessary packages, configuring our app to use JSON, cookies, and CORS, and initializing environment variables with dotenv:

import express, { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import cookieParser from "cookie-parser";
import cors from "cors";
import dotenv from "dotenv";
import { randomUUID as uuidv4 } from "crypto";

// Load environment variables
dotenv.config();

// Create an Express app
const app = express();

// Enable JSON body parsing and cookies
app.use(express.json());
app.use(cookieParser());

// CORS setup to allow cross-domain requests (with cookies)
app.use(
  cors({
    credentials: true,
    origin: ["http://localhost:5500", "http://127.0.0.1:5500"],
  })
);

Notice the credentials: true and specifying the frontend origin array ensure that cookies are sent with requests to our server from those domains.

2. In-Memory “Database”

For simplicity, we’ll store our user and token data in memory. In production, replace this with a real database (like PostgreSQL, MongoDB, etc.):

const db = {
  // Our only user: "admin" / "password"
  users: [{ id: 1, username: "admin", password: "password" }],

  // Mapping of user IDs to their valid refresh tokens
  // e.g. refreshTokens[1] = ""
  refreshTokens: {} as Record<string, string>,
} as const;

Key points:

  • db.refreshTokens holds each user’s valid refresh token. If a token isn’t in here, it’s invalid.
  • We mark everything as readonly (as const) just for clarity in our toy example.

Secrets & Utility Functions

We read our JWT secrets from environment variables, then define two helper functions to generate tokens:

const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const REFRESH_SECRET = process.env.REFRESH_SECRET!;

// A simple lookup for the latest refresh token per user
let refreshTokens: Record<string, string> = {};

// Generate a short-lived access token (5s for demo; ~15min for real apps)
const generateAccessToken = (user: any) =>
  jwt.sign({ id: user.id }, ACCESS_SECRET, { expiresIn: "5s" });

// Generate a refresh token with a unique tokenId for rotation
const generateRefreshToken = (user: any) => {
  const tokenId = uuidv4(); // unique ID for the refresh token
  const token = jwt.sign({ id: user.id, tokenId }, REFRESH_SECRET, {
    expiresIn: "7d",
  });
  refreshTokens[user.id] = token;
  return token;
};

Key Points

  • Access Token (generateAccessToken):
    • Short expiration (5 seconds here just for demonstration).
    • Should contain minimal user info (here, only id).
  • Refresh Token (generateRefreshToken):
    • Longer expiration (7 days).
    • Includes a tokenIdto facilitate more advanced revocation strategies if needed.
    • Stored in refreshTokens[user.id] so only the latest token is valid.

4. Login Route

When a user logs in, we generate an access token and a refresh token. The access token is returned in the JSON response, and the refresh token is stored in an HTTP-only cookie:

app.post("/auth/login", (req, res) => {
  const { username, password } = req.body;

  // Basic credential check (plaintext for demo)
  const user = db.users.find(
    (u) => u.username === username && u.password === password
  );
  if (!user) {
    res.status(401).json({ message: "Invalid credentials" });
    return;
  }

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

  // Also store it in db.refreshTokens to track validity
  db.refreshTokens[user.id] = refreshToken;

  // Send refresh token as HTTP-only cookie (unreadable by JS)
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: "none",
  });

  // The access token is returned in the response body
  res.json({ accessToken });
});

Key points:

  • Invalid Credentials: If username/password don’t match, we return a 401.
  • Token Generation: We get both the short-lived access and long-lived refresh tokens.
  • HTTP-Only Cookie: httpOnly: true means JavaScript can’t read this cookie, which protects against XSS.
  • Response: The client (frontend) will store the access token in localStorage (or in-memory) for easy usage in API calls.

5. Refresh Route (Token Rotation)

When the short-lived access token expires, the frontend sends a request to /auth/refresh to get a new one. This route:

  1. Reads the old refresh token from the cookie.
  2. Validates it with jwt.verify().
  3. Ensures it matches the latest stored token for the user (prevents using old tokens).
  4. Generates brand-new tokens and invalidates the old refresh token, implementing rotation.
app.post("/auth/refresh", (req, res) => {
  const oldToken = req.cookies.refreshToken;

  if (!oldToken) {
    res.status(401).json({ message: "Unauthorized" });
    return;
  }

  jwt.verify(oldToken, REFRESH_SECRET, (err: any, user: any) => {
    // If token is invalid or not the latest one
    if (err || db.refreshTokens[user.id] !== oldToken) {
      res.clearCookie("refreshToken");
      res.status(401).json({ message: "Invalid refresh token" });
      return;
    }

    // Remove the old token from "db"
    delete db.refreshTokens[user.id];

    // Generate brand-new tokens
    const newAccessToken = generateAccessToken(user);
    const newRefreshToken = generateRefreshToken(user);

    // Store the new refresh token
    db.refreshTokens[user.id] = newRefreshToken;

    // Send the new refresh token via HTTP-only cookie
    res.cookie("refreshToken", newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: "none",
      path: "/",
    });

    // Return the new access token in body
    res.json({ accessToken: newAccessToken });
  });
});

Key points:

  • Rotating Tokens: By deleting the old token from db.refreshTokens and generating a new one each time, we ensure that once the old token has been used, it can’t be reused.
  • Clearing Cookies: If the token is invalid, we clear the refresh cookie and respond with 401 Unauthorized.

6. Auth Middleware (Protecting Routes)

This middleware verifies that the access token on the Authorization: Bearer ... header is valid. If it is, we attach the user info to req.user; if not, we return 401 or 403.

export const authMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1];

  if (!token) {
    res.status(401).json({ message: "Unauthorized" });
    return;
  }

  jwt.verify(token, ACCESS_SECRET, (err, user) => {
    if (err) {
      res.status(403).json({ message: "Forbidden" });
      return;
    }

    req.user = user as any;
    next();
  });
};

Key points:

  • We look for Authorization: Bearer .
  • If the token is expired or invalid, jwt.verify() triggers an error.
  • If everything checks out, the request proceeds to the next middleware/handler.

7. Protected Route

This is an example route that requires a valid access token. It won’t be reached unless our authMiddlewaresuccessfully verifies the token.

app.get("/protected/secret", authMiddleware, (_, res) => {
  res.json({ message: "This is a secret data!" });
});

8. Start the Server

Finally, we just start our app listening on port 5000.

app.listen(5000, () => console.log("Server running on port 5000