JWT Authentication in Express.js with TypeScript & Refresh Tokens
Learn how to build secure JWT Authentication in Express.js using TypeScript, MongoDB, Mongoose, Access Tokens, Refresh Tokens, HTTP-only cookies, and custom middleware.
In this blog, we will build a production-grade, secure JWT Authentication system using Express.js, TypeScript, and MongoDB.
🛠️ Production Boilerplate Note
The Password Auth setup used in this tutorial is also included inside my open-source template, express-ts-template, alongside other features like Google auth, file uploads, and a structured production-ready backend setup.
Before touching authentication, we build reusable helper classes. This keeps frontend handling much cleaner since every response follows the same structure.
exportclassApiErrorextendsError {
statusCode:number;
message:string;
errors:any[];
stack?:string;
data:any;
success:boolean;
constructor(
statusCode:number,
message="Something went wrong",
errors= [],
stack=""
) {
super(message)
this.statusCode = statusCode
this.data =null
this.message = message
this.success =false
this.errors = errors
if (stack) {
this.stack = stack
} else {
Error.captureStackTrace(this, this.constructor)
}
}
}
exportclassApiResponse {
statusCode:number;
data:any;
message:string;
success:boolean;
constructor(
statusCode:number,
data:any,
message:string="Success"
) {
this.statusCode = statusCode
this.data = data
this.message = message
this.success = statusCode <400
}
}
💡 Why use this pattern?
Instead of manually typing return res.status(200).json({ success: true, data }) in dozens of locations, these utility classes enforce structural consistency across your entire backend footprint.
Instead of hashing passwords manually inside controllers every time, we move the logic into the schema itself using pre-save hooks and custom schema methods.
import { model, Schema, type HydratedDocument } from"mongoose";
import jwt, { type Secret, type SignOptions } from"jsonwebtoken";
Instead of issuing and saving tokens inside our login controller, we create an independent utility helper. This utility issues the pairs and commits the refresh token to the database.
// Store the refresh token in the database to manage active sessions
user.refreshToken = refreshToken;
await user.save({ validateBeforeSave: false });
return { accessToken, refreshToken };
} catch (err) {
thrownewApiError(500, "Error while generating authentication tokens");
}
};
🔒 Security Checkpoint: Why save the Refresh Token to the database?
Storing refresh tokens statefully allows the backend to force a hard logout, instantly invalidate stolen sessions, and roll out security practices like refresh token rotation.
Any resource route requiring active authentication runs through this middleware. It validates the incoming token and attaches the complete user details to the Request lifecycle object.
src/middlewares/auth.middleware.ts
import { type NextFunction, type Request, type Response } from"express";
import { User } from"../models/User.model.js";
import { ApiError } from"../utils/ApiError.js";
import jwt, { type JwtPayload } from"jsonwebtoken";
thrownewApiError(401, "Authentication failed: Revoked user authorization");
}
req.user = user;
next();
} catch (error:any) {
next(newApiError(401, error?.message ||"Invalid or expired access token"));
}
};
🔒 Security Checkpoint: Why verify against the Database?
A standalone JWT verification can successfully pass if it hasn’t expired yet—even if the user was deleted, banned, or had their account deactivated 2 minutes ago. Checking the database explicitly stops these zombie sessions instantly.
Now, let’s assemble our system components within our routing configurations. By chaining our controllers, we inject our verifyJWT gatekeeper directly in front of endpoints that demand active session contexts (like /logout).
💡 Intermediate Tip: Streamlining Bulk Protected Routes
If you have dedicated route files where every single endpoint requires authentication (such as user.routes.ts or dashboard.routes.ts), passing verifyJWT individually can lead to repetitive code. Instead, mount the middleware at the root layout of the router:
constrouter=Router();
// Mount globally onto this specific router group instance
router.use(verifyJWT);
// Every endpoint listed down below is automatically secured!
By default, Express does not have a `user` property inside its global `Request` object. We use declaration merging to inject our custom Mongoose Document model type safely.
`src/@types/express/index.d.ts`
```ts
import { type IUserDocument } from "../../models/User.model.js";
declare module "express-serve-static-core" {
interface Request {
user?: IUserDocument;
}
}
export {};
Make sure your tsconfig.json knows where to compile and read your local definitions files: