Sunday, March 1, 2026
HomeTechnologyAdd Refresh Tokens to a Node.js + Express + TypeScript API (JWT...

Add Refresh Tokens to a Node.js + Express + TypeScript API (JWT Auth Done Right)

Alright — next we’ll make the auth system real-world by adding refresh tokens (plus a safer access-token flow).

Right now, the API uses a single JWT that lasts a day. That’s fine for demos, but in production you want:

  • Short-lived access tokens (minutes)
  • Long-lived refresh tokens (days/weeks)
  • A way to rotate refresh tokens and revoke them if needed

This is the most common modern pattern.


Add Refresh Tokens to a Node.js + Express + TypeScript API (JWT Auth Done Right)

What We’re Building

We’ll implement:

  • POST /api/auth/register → creates user + returns tokens
  • POST /api/auth/login → returns tokens
  • POST /api/auth/refresh → gives a new access token using refresh token
  • POST /api/auth/logout → revokes refresh token
  • Access token used in Authorization: Bearer <token>

We’ll also store refresh tokens in the database (hashed), so they can be revoked.


Step 1: Update .env

Add more auth settings:

PORT=5000
MONGO_URI=mongodb://localhost:27017/ts-api
JWT_ACCESS_SECRET=access_secret_change_me
JWT_REFRESH_SECRET=refresh_secret_change_me
ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=30d

Use different secrets for access and refresh tokens.


Step 2: Extend the User Model to Store Refresh Tokens

Update src/models/User.ts:

import mongoose, { Document, Schema } from "mongoose";

export interface IUser extends Document {
  name: string;
  email: string;
  password: string;
  refreshTokenHash?: string;   // store hashed refresh token
  createdAt: Date;
  updatedAt: Date;
}

const UserSchema: Schema<IUser> = new Schema(
  {
    name: { type: String, required: true },
    email: { type: String, required: true, unique: true },
    password: { type: String, required: true },
    refreshTokenHash: { type: String }
  },
  { timestamps: true }
);

export default mongoose.model<IUser>("User", UserSchema);

We’ll save only a hash of the refresh token. If your DB leaks, tokens aren’t immediately usable.


Step 3: Token Helpers

Create: src/utils/tokens.ts

import jwt from "jsonwebtoken";

export const signAccessToken = (userId: string) => {
  return jwt.sign(
    { id: userId },
    process.env.JWT_ACCESS_SECRET as string,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRES_IN || "15m" }
  );
};

export const signRefreshToken = (userId: string) => {
  return jwt.sign(
    { id: userId },
    process.env.JWT_REFRESH_SECRET as string,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN || "30d" }
  );
};

Step 4: Add Login + Refresh + Logout Controllers

Update src/controllers/authController.ts:

import { Request, Response } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import User from "../models/User";
import { signAccessToken, signRefreshToken } from "../utils/tokens";

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();

  return res.status(201).json({ accessToken, refreshToken });
};

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();

  return res.json({ accessToken, refreshToken });
};

export const refreshAccessToken = async (req: Request, res: Response) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(400).json({ message: "Refresh token is required" });
  }

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

    // Rotation: issue a new refresh token and replace stored hash
    const newAccessToken = signAccessToken(user._id.toString());
    const newRefreshToken = signRefreshToken(user._id.toString());

    user.refreshTokenHash = await hashToken(newRefreshToken);
    await user.save();

    return res.json({
      accessToken: newAccessToken,
      refreshToken: newRefreshToken
    });
  } catch {
    return res.status(401).json({ message: "Refresh token invalid" });
  }
};

export const logoutUser = async (req: Request, res: Response) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(400).json({ message: "Refresh token is required" });
  }

  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();
    }

    return res.json({ message: "Logged out" });
  } catch {
    return res.status(200).json({ message: "Logged out" }); 
    // Don't reveal token validity
  }
};

This implements refresh token rotation and revocation.


Step 5: Update Routes

src/routes/authRoutes.ts

import { Router } from "express";
import {
  registerUser,
  loginUser,
  refreshAccessToken,
  logoutUser
} from "../controllers/authController";

const router = Router();

router.post("/register", registerUser);
router.post("/login", loginUser);
router.post("/refresh", refreshAccessToken);
router.post("/logout", logoutUser);

export default router;

Step 6: Update Auth Middleware to Verify Access Tokens

Update src/middleware/authMiddleware.ts:

import { Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { AuthRequest } from "../types/express";

export const protect = (
  req: AuthRequest,
  res: Response,
  next: NextFunction
) => {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ message: "Not authorized" });
  }

  const token = authHeader.split(" ")[1];

  try {
    const decoded = jwt.verify(
      token,
      process.env.JWT_ACCESS_SECRET as string
    ) as { id: string };

    req.user = decoded;
    next();
  } catch {
    return res.status(401).json({ message: "Access token invalid" });
  }
};

How the Client Should Use This

Login/Register

  • Save both tokens (preferably refresh token in httpOnly cookie in real apps)

Calling protected routes

  • Use Authorization: Bearer <accessToken>

When access token expires

  • Call /refresh with refresh token
  • Get new tokens
  • Retry original request

Logout

  • Call /logout (revokes refresh token)

Next Improvements (Real Production Hardening)

If you want the next “next”, here are the best upgrades:

  1. Store refresh token in an httpOnly secure cookie (instead of JSON response)
  2. Add rate limiting for /login and /refresh
  3. Add Zod validation for request bodies
  4. Add a service layer (controllers become thin)
  5. Add RBAC roles and permissions
  6. Add Swagger docs so your API is self-documented

Say “next” again and I’ll continue with the best one: httpOnly cookie refresh tokens + CSRF-safe approach (the most common production setup).

RELATED ARTICLES

Most Popular

Recent Comments