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

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<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
tokenId
to 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:
- Reads the old refresh token from the cookie.
- Validates it with
jwt.verify()
. - Ensures it matches the latest stored token for the user (prevents using old tokens).
- 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 authMiddleware
successfully 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