Skip to content
Neerav Gupta

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.

Backend , Authentication 2 min read

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

Terminal window
└── src/
├── @types/
├── controllers/
├── middlewares/
├── models/
├── routes/
├── utils/
├── app.ts
└── index.ts

Terminal window
npm install jsonwebtoken bcrypt mongoose cookie-parser
npm install -D @types/jsonwebtoken @types/bcrypt

Create 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: true

Benefits:

  • 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 🚀

Comments