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 = {
users: [{ id: 1, username: "admin", password: "password" }],
// Mapping of user IDs to their valid refresh tokens
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!;
// 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;
};
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 🎇"));
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>
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";
});
Key aspects of the frontend implementation:
- Access token is stored in localStorage
- Refresh token is handled automatically via HTTP-only cookies
- Automatic token refresh when making API calls
- Login state management
- 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:
- Token Expiration: Adjust the access token expiration to a practical value (5-15 minutes)
- Database Storage: Use a persistent database to store refresh tokens
- Token Cleanup: Implement a mechanism to clean up expired refresh tokens
- Rate Limiting: Add rate limiting to prevent brute force attacks
- HTTPS: Ensure all communication happens over HTTPS
- Password Hashing: Use proper password hashing (bcrypt/Argon2) instead of storing plaintext passwords
- 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)