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 tokensPOST /api/auth/login→ returns tokensPOST /api/auth/refresh→ gives a new access token using refresh tokenPOST /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
/refreshwith 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:
- Store refresh token in an httpOnly secure cookie (instead of JSON response)
- Add rate limiting for
/loginand/refresh - Add Zod validation for request bodies
- Add a service layer (controllers become thin)
- Add RBAC roles and permissions
- 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).