Project Structure
└── src/
├── @types/
├── controllers/
├── middlewares/
├── models/
├── routes/
├── utils/
├── app.ts
└── index.ts
Install Required Packages
npm install jsonwebtoken bcrypt mongoose cookie-parser
npm install -D @types/jsonwebtoken @types/bcrypt
Environment Variables
Create a .env file:
ACCESS_TOKEN_SECRET=
ACCESS_TOKEN_EXPIRY=
REFRESH_TOKEN_SECRET=
REFRESH_TOKEN_EXPIRY=
Custom ApiResponse & ApiError
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
ApiError Helper
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)
}
}
}
ApiResponse Helper
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
}
}
Why Use This Pattern?
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.
Why Use Access & Refresh Tokens?
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.
User Model
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);
Why Use Mongoose Methods?
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
Password Hashing with Mongoose Middleware
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
Compare Passwords
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.
Token Generation Helper
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");
}
}
Why Store Refresh Tokens in Database?
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.
Register User Controller
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")
)
};
Why Remove Password & Refresh Token?
.select("-password -refreshToken")
Never send sensitive fields back to the client.
Even hashed passwords should never leave the backend.
Login Controller
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")
);
};
Why Use Cookies Instead of LocalStorage?
We use:
httpOnly: true
Benefits:
- Protects against XSS attacks
- JavaScript cannot access tokens
- More secure than localStorage
Cookie Options
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict" as const,
};
Verify JWT Middleware
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");
}
};
Why Check Database After JWT Verification?
Some tutorials only verify the JWT.
We additionally verify if the user still exists.
Benefits:
- Prevents deleted-user access
- Safer authentication
- Better security
Refresh Access Token Controller
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");
}
};
Logout Controller
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"));
};
Extend Express Request Types
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 {};
tsconfig.json
{
"compilerOptions": {
"typeRoots": [
"./node_modules/@types",
"./src/@types"
]
},
"include": [
"src/**/*",
"src/@types/**/*.d.ts"
]
}
Final Thoughts
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 🚀
Top comments (0)