Sunday, March 1, 2026
HomeTechnologyJWT Auth with httpOnly Refresh Token Cookies (Express + TypeScript)

JWT Auth with httpOnly Refresh Token Cookies (Express + TypeScript)

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.com
  • COOKIE_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:

  • path limits 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:

  1. Add Zod validation + clean error responses (most useful immediately)
  2. Add rate limiting + lockout for brute-force login attempts
  3. Add RBAC (roles + permissions) for real apps
  4. Add Swagger/OpenAPI docs
  5. Add Docker + production deploy checklist

If you just say “next” again, I’ll pick the most practical: Zod validation + centralized error handling.

RELATED ARTICLES

Most Popular

Recent Comments