JWT Authentication in Express.js with TypeScript, Mongoose & 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 complete JWT Authentication system using:
- Express.js
- TypeScript
- MongoDB + Mongoose
- JWT Authentication
- Access & Refresh Tokens
- HTTP-only cookies
We will implement:
- User Registration
- Login
- Logout
- Refresh Tokens
- Protected Routes
- Password Hashing
└── src/ ├── @types/ ├── controllers/ ├── middlewares/ ├── models/ ├── routes/ ├── utils/ ├── app.ts └── index.tsnpm install jsonwebtoken bcrypt mongoose cookie-parsernpm install -D @types/jsonwebtoken @types/bcryptCreate a .env file:
ACCESS_TOKEN_SECRET=ACCESS_TOKEN_EXPIRY=
REFRESH_TOKEN_SECRET=REFRESH_TOKEN_EXPIRY=Before building authentication, I use custom helper classes for consistent API responses and error handling.
Benefits:
- Cleaner controller code
- Consistent API structure
- Easier frontend handling
- Better debugging
- Scalable architecture
src/utils/ApiError.ts
export class ApiError extends Error { 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) } }}src/utils/ApiResponse.ts
export class ApiResponse { 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 }}Instead of manually writing responses like:
return res.status(200).json({ success: true, message: "Logged in", data,});everywhere, helper classes keep responses standardized across the entire backend.
A common mistake beginners make is using only one JWT token.
Instead, we use:
- Access Token → short-lived authentication
- Refresh Token → generates new access tokens
Benefits:
- Better security
- Users stay logged in
- Easier session invalidation
- Refresh token rotation support
This is the same architecture used in many production apps.
src/models/User.model.ts
import { model, Schema, type HydratedDocument } from "mongoose";import jwt, { type Secret, type SignOptions } from "jsonwebtoken";import bcrypt from "bcrypt";
export interface IUser { username: string; fullName: string; email: string; password: string; refreshToken?: string;
generateAccessToken: () => string; generateRefreshToken: () => string; isPasswordCorrect: (password: string) => Promise<boolean>;}
export type IUserDocument = HydratedDocument<IUser>;
const userSchema = new Schema<IUser>({ username: String fullName: String email: String password: String refreshToken: String});
userSchema.pre("save", async function (): Promise<void> { if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 10);});
userSchema.methods.isPasswordCorrect = async function ( password: string): Promise<boolean> { return await bcrypt.compare(password, this.password);};
userSchema.methods.generateAccessToken = function (): string { return jwt.sign( { _id: this._id, email: this.email, username: this.username, fullName: this.fullName, }, process.env.ACCESS_TOKEN_SECRET!! as Secret, { expiresIn: process.env.ACCESS_TOKEN_EXPIRY!!, } as SignOptions );};
userSchema.methods.generateRefreshToken = function (): string { return jwt.sign( { _id: this._id, }, process.env.REFRESH_TOKEN_SECRET!! as Secret, { expiresIn: process.env.REFRESH_TOKEN_EXPIRY!!, } as SignOptions );};
export const User = model<IUser>("User", userSchema);Instead of creating separate utility functions, we attach methods directly to the schema.
Benefits:
- Cleaner code
- Better TypeScript support
- Easier reusability
- Logic stays close to the model
userSchema.pre("save", async function (): Promise<void> { if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 10);});Why this is good:
- Password hashing becomes automatic
- Prevents accidental plaintext passwords
- Avoids duplicate hashing logic
userSchema.methods.isPasswordCorrect = async function ( password: string): Promise<boolean> { return await bcrypt.compare(password, this.password);};We use bcrypt.compare() because hashed passwords cannot be decrypted.
const generateTokens = async (userId: Types.ObjectId | string): Promise<{ accessToken: string; refreshToken: string }> => { try { const user = await User.findById(userId); if (!user) { throw new ApiError(404, "User not found"); }
const refreshToken = user.generateRefreshToken(); const accessToken = user.generateAccessToken();
user.refreshToken = refreshToken; await user.save({ validateBeforeSave: false });
return { accessToken, refreshToken }; } catch (err) { console.log(err) throw new ApiError(500, "Error while generating tokens"); }}Many tutorials skip this.
Storing refresh tokens allows us to:
- Logout users properly
- Invalidate sessions
- Rotate refresh tokens
- Detect token reuse attacks
This is much safer than purely stateless JWT auth.
export const registerUser = async (req: Request, res: Response) => { const { username, email, fullName, password } = req.body;
if ( [fullName, email, username, password].some((field) => field?.trim() === "") ) { throw new ApiError(400, "All fields are required"); }
const userExists = await User.findOne({ $or: [{ username }, { email }] });
if (userExists) { throw new ApiError(409, "User with the same username or email already exists"); }
const user = await User.create({ fullName, email, password, username: username.toLowerCase(), });
const createdUser = await User.findById(user._id).select("-password -refreshToken");
if (!createdUser) { throw new ApiError(500, "Error while creating user"); }
return res.status(201).json( new ApiResponse(201, createdUser, "User registered successfully") )
};.select("-password -refreshToken")Never send sensitive fields back to the client.
Even hashed passwords should never leave the backend.
export const loginUser = async (req: Request, res: Response) => {
const { username, email, password } = req.body;
if (!username && !email) { throw new ApiError(400, "Username or email is required"); }
if (!password) { throw new ApiError(400, "Password is required"); }
const user = await User.findOne({ $or: [{ username }, { email }] });
if (!user) { throw new ApiError(404, "User not found"); }
const isPassValid: boolean = await user.isPasswordCorrect(password); if (!isPassValid) { throw new ApiError(401, "Invalid password"); }
const { accessToken, refreshToken } = await generateTokens(user._id);
const userData = { _id: user._id, fullName: user.fullName, username: user.username, email: user.email, avatarUrl: user.avatarUrl, }
return res .status(200) .cookie("accessToken", accessToken, cookieOptions) .cookie("refreshToken", refreshToken, cookieOptions) .json( new ApiResponse(200, { user: userData, accessToken, refreshToken }, "User logged in successfully") );};We use:
httpOnly: trueBenefits:
- Protects against XSS attacks
- JavaScript cannot access tokens
- More secure than localStorage
const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict" as const,};src/middlewares/auth.middleware.ts
import type { NextFunction, Request, Response } from "express";import { User } from "../models/User.model.js";import { ApiError } from "../utils/ApiError.js";import { asyncHandler } from "../utils/asyncHandler.js";import jwt, { type JwtPayload } from "jsonwebtoken";
export const verifyJWT = async (req: Request, _: Response, next: NextFunction) => { try { const accessToken = req.cookies?.accessToken || req.header("Authorization")?.replace("Bearer ", "");
if (!accessToken) { throw new ApiError(401, "Access token is missing"); }
const decodedToken = jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET!!) as JwtPayload;
const user = await User.findById(decodedToken._id).select("-password -refreshToken");
if (!user) { throw new ApiError(401, "Invalid Access Token"); }
req.user = user; next(); } catch (error: any) { throw new ApiError(401, error?.message || "Invalid Access Token"); }};Some tutorials only verify the JWT.
We additionally verify if the user still exists.
Benefits:
- Prevents deleted-user access
- Safer authentication
- Better security
export const refreshAccessToken = async (req: Request, res: Response) => { const incomingRefreshToken = req.cookies.refreshToken || req.body.refreshToken
if (!incomingRefreshToken) { throw new ApiError(401, "Refresh token is missing"); }
try { const decodedToken = jwt.verify(incomingRefreshToken, process.env.REFRESH_TOKEN_SECRET!!) as JwtPayload;
const user = await User.findById(decodedToken?._id);
if (!user) { throw new ApiError(401, "Invalid refresh token - user not found"); }
if (user.refreshToken !== incomingRefreshToken) { throw new ApiError(401, "Invalid refresh token"); }
const { accessToken, refreshToken: newRefreshToken } = await generateTokens(user._id);
return res .status(200) .cookie("accessToken", accessToken, cookieOptions) .cookie("refreshToken", newRefreshToken, cookieOptions) .json( new ApiResponse(200, { accessToken, refreshToken: newRefreshToken, }, "Access token refreshed successfully" ) ); } catch (err: any) { throw new ApiError(401, err?.message || "Invalid refresh token"); }};export const logoutUser = async (req: Request, res: Response) => { const userId = req.user?._id;
if (!userId) { throw new ApiError(400, "User ID is required"); }
await User.findByIdAndUpdate(userId, { $unset: { refreshToken: 1 } }, { returnDocument: "after" });
return res .status(200) .clearCookie("accessToken", cookieOptions) .clearCookie("refreshToken", cookieOptions) .json(new ApiResponse(200, {}, "User logged out successfully"));};src/@types/express/index.d.ts
import type { IUserDocument } from "../../models/User.model.js";
declare module "express-serve-static-core" { interface Request { user?: IUserDocument; }}
export {};{ "compilerOptions": { "typeRoots": [ "./node_modules/@types", "./src/@types" ] },
"include": [ "src/**/*", "src/@types/**/*.d.ts" ]}You now have a production-style JWT Authentication system with:
- Access Tokens
- Refresh Tokens
- HTTP-only cookies
- Protected routes
- Password hashing
- Token rotation
- Secure logout
You can further improve this by adding:
- Email verification
- Password reset
- Rate limiting
- 2FA authentication
- OAuth logins
- Redis session storage
Happy coding 🚀