DEV Community

Wiljeder Filho
Wiljeder Filho

Posted on

1

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"],
  })
);
Enter fullscreen mode Exit fullscreen mode

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 = {
  users: [{ id: 1, username: "admin", password: "password" }],

  // Mapping of user IDs to their valid refresh tokens
  refreshTokens: {} as Record<string, string>,
} as const;
Enter fullscreen mode Exit fullscreen mode

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!;

// 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",
  });

  return token;
};
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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 });
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
};
Enter fullscreen mode Exit fullscreen mode

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 authMiddleware successfully verifies the token.

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

8. Start the Server

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

app.listen(5000, () => console.log("Server running on port 5000 🎇"));
Enter fullscreen mode Exit fullscreen mode

Building the Frontend

We have a simple HTML page and a corresponding JavaScript file. The HTML provides a basic login form and buttons for secret data fetching and logging out. The JS handles authentication logic (login, token refresh, and protected resource access).

<!DOCTYPE html>
<html lang="en">
  <head>
    <script defer src="script.js"></script>
  </head>
  <body>
    <div id="loader">Loading...</div>
    <div id="content" style="display: none;">
      <form id="login-form">
        <h2>Login</h2>
        <input id="username" placeholder="Username" />
        <input id="password" type="password" placeholder="Password" />
        <button type="submit">Login</button>
      </form>

      <button id="secret-button" onclick="fetchSecret()">Get Secret</button>
      <button id="logout-button" onclick="logout()" style="display: none;">
        Logout
      </button>

      <p id="message"></p>
    </div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The JavaScript handles the authentication logic:

const API_URL = "http://localhost:5000"; 
// Or wherever your backend is running

// DOM helpers
function toggleVisibility(elementId, visible) {
  const element = document.getElementById(elementId);
  if (element) {
    element.style.display = visible ? "block" : "none";
  }
}

function setMessage(message) {
  const messageElement = document.getElementById("message");
  messageElement.innerText = message;
  // Clear after 3 seconds
  setTimeout(() => {
    messageElement.innerText = "";
  }, 3000);
}

// Authentication functions
async function login(e) {
  e.preventDefault();

  try {
    const res = await fetch(`${API_URL}/auth/login`, {
      method: "POST",
      credentials: "include", // Important for cookies
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        username: document.getElementById("username").value,
        password: document.getElementById("password").value,
      }),
    });

    if (!res.ok) {
      setMessage("Failed to login!");
      return;
    }

    const { accessToken } = await res.json();
    localStorage.setItem("accessToken", accessToken);
    setMessage("Logged in!");

    toggleVisibility("logout-button", true);
    toggleVisibility("login-form", false);
  } catch (error) {
    console.error("Login failed:", error);
    setMessage("Login error occurred!");
  }
}

async function refreshToken() {
  const res = await fetch(`${API_URL}/auth/refresh`, {
    method: "POST",
    credentials: "include", // Include cookies
  });

  if (res.ok) {
    const { accessToken } = await res.json();
    localStorage.setItem("accessToken", accessToken);
    return accessToken;
  }

  // Handle refresh failure
  localStorage.removeItem("accessToken");
  setMessage("Session expired. Please log in again.");
  toggleVisibility("login-form", true);
  toggleVisibility("logout-button", false);
  throw new Error("Failed to refresh token");
}

async function fetchSecret() {
  let token = localStorage.getItem("accessToken");
  if (!token) {
    setMessage("Please log in first");
    return;
  }

  try {
    // First attempt
    let res = await fetch(`${API_URL}/protected/secret`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
      credentials: "include",
    });

    // If the token has expired or is invalid, try refreshing
    if (!res.ok) {
      token = await refreshToken();
      res = await fetch(`${API_URL}/protected/secret`, {
        headers: {
          Authorization: `Bearer ${token}`,
        },
        credentials: "include",
      });
    }

    if (res.ok) {
      const data = await res.json();
      setMessage(data.message);
    } else {
      setMessage("Failed to fetch secret.");
    }
  } catch (error) {
    console.error("Error in fetchSecret:", error);
    setMessage("Error occurred while fetching secret.");
  }
}

function logout() {
  localStorage.removeItem("accessToken");
  toggleVisibility("logout-button", false);
  toggleVisibility("login-form", true);
  setMessage("Logged out!");
}

// Initialize
document.addEventListener("DOMContentLoaded", async () => {
  // Setup login listener
  document.getElementById("login-form").addEventListener("submit", login);

  // If we already have a token, attempt to use it
  const accessToken = localStorage.getItem("accessToken");
  if (accessToken) {
    try {
      await fetchSecret();
      toggleVisibility("login-form", false);
      toggleVisibility("logout-button", true);
    } catch (error) {
      // If the token is invalid, reset everything
      logout();
    }
  }

  // Hide the loader, show content
  toggleVisibility("loader", false);
  document.getElementById("content").style.display = "block";
});
Enter fullscreen mode Exit fullscreen mode

Key aspects of the frontend implementation:

  1. Access token is stored in localStorage
  2. Refresh token is handled automatically via HTTP-only cookies
  3. Automatic token refresh when making API calls
  4. Login state management
  5. Proper credentials inclusion for CORS requests with cookies

Security Benefits

This implementation provides several security advantages:

1. Protection Against XSS Attacks

By storing the refresh token in an HTTP-only cookie, we protect it from JavaScript access. Even if an attacker manages to inject malicious JavaScript, they cannot steal the refresh token.

2. Token Rotation

Each time a refresh token is used, a new refresh token is issued and the old one is invalidated. This means that if a refresh token is somehow compromised, it becomes useless as soon as it's used.

3. Short-lived Access Tokens

By keeping access tokens short-lived (5-15 minutes in production), we minimize the window of opportunity for attackers if an access token is somehow leaked.

Practical Considerations for Production

When implementing this in a production environment:

  1. Token Expiration: Adjust the access token expiration to a practical value (5-15 minutes)
  2. Database Storage: Use a persistent database to store refresh tokens
  3. Token Cleanup: Implement a mechanism to clean up expired refresh tokens
  4. Rate Limiting: Add rate limiting to prevent brute force attacks
  5. HTTPS: Ensure all communication happens over HTTPS
  6. Password Hashing: Use proper password hashing (bcrypt/Argon2) instead of storing plaintext passwords
  7. Account Lockout: Implement account lockout after multiple failed attempts

Conclusion

This tutorial demonstrates a secure approach to JWT authentication using Express and vanilla JavaScript. By storing refresh tokens in HTTP-only cookies and implementing token rotation, we significantly enhance the security of our authentication system while maintaining a seamless user experience.

The complete source code is available on this GitHub repo, where you can see the full implementation in action.

Top comments (0)