Alright — next we’ll do the production standard version:
- Refresh token stored in an httpOnly cookie (not in localStorage, not readable by JS)
- Access token returned in JSON (short-lived)
- CSRF-safe approach using SameSite cookies + a double-submit CSRF token option (so you can use cookies safely)
- CORS configured correctly for a separate frontend domain
This is the setup you’ll see in a lot of real apps.
JWT Auth with httpOnly Refresh Token Cookies (Express + TypeScript)
Why move refresh tokens into httpOnly cookies?
If you store refresh tokens in localStorage, a single XSS bug can steal them.
An httpOnly cookie:
- is automatically sent by the browser
- can’t be read by JavaScript
- reduces token theft risk
Tradeoff: cookies can introduce CSRF risk, so we mitigate it.
Step 1: Install Dependencies
Add cookie support + CORS:
npm install cookie-parser cors
npm install -D @types/cookie-parser @types/cors
Step 2: Set Secure Cookie/CORS Settings in .env
CLIENT_ORIGIN=http://localhost:3000
COOKIE_SECURE=false
In production:
CLIENT_ORIGIN=https://yourfrontend.comCOOKIE_SECURE=true
Step 3: Configure Express for Cookies + CORS
Update src/server.ts:
import express from "express";
import dotenv from "dotenv";
import cors from "cors";
import cookieParser from "cookie-parser";
import { connectDB } from "./config/db";
import authRoutes from "./routes/authRoutes";
dotenv.config();
const app = express();
connectDB();
app.use(express.json());
app.use(cookieParser());
// Important: allow cookies across origins
app.use(
cors({
origin: process.env.CLIENT_ORIGIN,
credentials: true
})
);
app.use("/api/auth", authRoutes);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Key point: credentials: true is required for cookies to be sent.
Step 4: Create Cookie Helper
src/utils/cookies.ts
import { Response } from "express";
export const setRefreshCookie = (res: Response, refreshToken: string) => {
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === "true",
sameSite: "lax", // good default for most apps
path: "/api/auth/refresh", // cookie only sent to refresh endpoint
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
};
export const clearRefreshCookie = (res: Response) => {
res.clearCookie("refreshToken", { path: "/api/auth/refresh" });
};
Notes:
pathlimits where the cookie is sent (reduces CSRF surface area)sameSite: "lax"blocks most cross-site POSTs by default
If you truly need cross-site usage (e.g., different domains with embedded flows), you may need sameSite: "none" + secure: true.
Step 5: Update Auth Controller to Use Cookies
Update src/controllers/authController.ts (key changes only, keeping the rest of your logic):
Register/Login: set cookie instead of returning refresh token
import { Request, Response } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import User from "../models/User";
import { signAccessToken, signRefreshToken } from "../utils/tokens";
import { setRefreshCookie, clearRefreshCookie } from "../utils/cookies";
const hashToken = async (token: string) => bcrypt.hash(token, 10);
export const registerUser = async (req: Request, res: Response) => {
const { name, email, password } = req.body;
const existing = await User.findOne({ email });
if (existing) return res.status(400).json({ message: "User already exists" });
const hashedPassword = await bcrypt.hash(password, 10);
const user = await User.create({ name, email, password: hashedPassword });
const accessToken = signAccessToken(user._id.toString());
const refreshToken = signRefreshToken(user._id.toString());
user.refreshTokenHash = await hashToken(refreshToken);
await user.save();
setRefreshCookie(res, refreshToken);
return res.status(201).json({ accessToken });
};
export const loginUser = async (req: Request, res: Response) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) return res.status(401).json({ message: "Invalid credentials" });
const match = await bcrypt.compare(password, user.password);
if (!match) return res.status(401).json({ message: "Invalid credentials" });
const accessToken = signAccessToken(user._id.toString());
const refreshToken = signRefreshToken(user._id.toString());
user.refreshTokenHash = await hashToken(refreshToken);
await user.save();
setRefreshCookie(res, refreshToken);
return res.json({ accessToken });
};
Refresh endpoint: read refresh token from cookie
export const refreshAccessToken = async (req: Request, res: Response) => {
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: "Not authorized" });
}
try {
const decoded = jwt.verify(
refreshToken,
process.env.JWT_REFRESH_SECRET as string
) as { id: string };
const user = await User.findById(decoded.id);
if (!user || !user.refreshTokenHash) {
return res.status(401).json({ message: "Not authorized" });
}
const valid = await bcrypt.compare(refreshToken, user.refreshTokenHash);
if (!valid) {
return res.status(401).json({ message: "Not authorized" });
}
// Rotate refresh token
const newAccessToken = signAccessToken(user._id.toString());
const newRefreshToken = signRefreshToken(user._id.toString());
user.refreshTokenHash = await hashToken(newRefreshToken);
await user.save();
setRefreshCookie(res, newRefreshToken);
return res.json({ accessToken: newAccessToken });
} catch {
return res.status(401).json({ message: "Not authorized" });
}
};
Logout: clear cookie and revoke token
export const logoutUser = async (req: Request, res: Response) => {
const refreshToken = req.cookies?.refreshToken;
if (refreshToken) {
try {
const decoded = jwt.verify(
refreshToken,
process.env.JWT_REFRESH_SECRET as string
) as { id: string };
const user = await User.findById(decoded.id);
if (user) {
user.refreshTokenHash = undefined;
await user.save();
}
} catch {
// intentionally ignore
}
}
clearRefreshCookie(res);
return res.json({ message: "Logged out" });
};
Step 6: Update Routes (No change, but note cookie usage)
src/routes/authRoutes.ts remains:
router.post("/register", registerUser);
router.post("/login", loginUser);
router.post("/refresh", refreshAccessToken);
router.post("/logout", logoutUser);
Client Usage Example (Fetch)
Login (cookie is set automatically)
await fetch("http://localhost:5000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password })
});
Refresh (cookie auto-sent)
const res = await fetch("http://localhost:5000/api/auth/refresh", {
method: "POST",
credentials: "include"
});
const data = await res.json();
The refresh cookie is never accessible in JS, which is the point.
CSRF: Do You Need Extra Protection?
With:
sameSite: "lax"- refresh cookie scoped to
/api/auth/refresh - refresh endpoint ideally requiring POST and not used cross-site
…you’re already reasonably protected for many apps.
If you must use sameSite: "none" (cross-site cookies), then you should add an explicit CSRF defense, like:
- Double-submit token (CSRF token in a non-httpOnly cookie + header)
- Or same-origin session strategy
Next “Next” Options (Best Choices)
Say “next” and I’ll continue with one of these:
- Add Zod validation + clean error responses (most useful immediately)
- Add rate limiting + lockout for brute-force login attempts
- Add RBAC (roles + permissions) for real apps
- Add Swagger/OpenAPI docs
- Add Docker + production deploy checklist
If you just say “next” again, I’ll pick the most practical: Zod validation + centralized error handling.