⚡ Quick Architecture Cheat Sheet (For Fast Revision)
If you are using this post to refresh your memory, here is the core token blueprint:
| Token Type | Stored In | Lifetime (Recommended) | Primary Purpose |
|---|---|---|---|
| Access Token | HTTP-Only Cookie / Auth Header | 15 Minutes | Authenticating short-lived protected route requests |
| Refresh Token | Database & HTTP-Only Cookie | 7 to 10 Days | Requesting a brand new Access Token when it expires |
The Token Lifecycle Flow
[Client] -------------- 1. Send Login Credentials ---------------> [Backend]
[Client] <-------- 2. Set Access & Refresh Cookies --------------- [Backend] (Saves Refresh Token to DB)
[Client] -------- 3. Access Protected Route (With Cookie) --------> [verifyJWT Middleware] -> [Granted]
Project Setup & Prerequisites
📂 Project Structure
└── src/
├── @types/
├── controllers/
├── middlewares/
├── models/
├── routes/
├── utils/
├── app.ts
└── index.ts
📥 Install Required Packages
Run the following commands to install the necessary production and development dependencies:
npm install jsonwebtoken bcrypt mongoose cookie-parser
npm install -D @types/jsonwebtoken @types/bcrypt
🌐 Environment Variables
Create a .env file in your root directory:
ACCESS_TOKEN_SECRET=your_super_secret_access_key
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_SECRET=your_super_secret_refresh_key
REFRESH_TOKEN_EXPIRY=7d
Step 1: Standardizing Global API Responses
Before touching authentication, we build reusable helper classes. This keeps frontend handling much cleaner since every response follows the same structure.
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)
}
}
}
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 typingreturn res.status(200).json({ success: true, data })in dozens of locations, these utility classes enforce structural consistency across your entire backend footprint.
Step 2: Designing the Data Layer (The User Model)
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";
import bcrypt from "bcrypt";
export interface IUser {
username: string;
fullName: string;
email: string;
password: string;
avatarUrl?: string;
refreshToken?: string;
generateAccessToken: () => string;
generateRefreshToken: () => string;
isPasswordCorrect: (password: string) => Promise<boolean>;
}
export type IUserDocument = HydratedDocument<IUser>;
const userSchema = new Schema<IUser>({
username: { type: String, required: true, unique: true, lowercase: true },
fullName: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
avatarUrl: { type: String },
refreshToken: { type: String }
});
// Automatic Password Hashing Hook
userSchema.pre("save", async function (next): Promise<void> {
if (!this.isModified("password")) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
// Secure Password Comparison Method
userSchema.methods.isPasswordCorrect = async function (password: string): Promise<boolean> {
return await bcrypt.compare(password, this.password);
};
// Access Token Generator
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
);
};
// Refresh Token Generator
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);
🧠 Revision Insight: Why handle Hashing & Token generation in the Model?
Automatic Hashing: The
pre("save")hook ensures you can never accidentally save a plaintext password to your database.Encapsulation: Controllers don't need to know how JWTs are signed or how passwords are encrypted. They simply call
user.generateAccessToken().
Step 3: Centralizing Token Orchestration
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.
src/utils/generateTokens.ts
import { Types } from "mongoose";
import { User } from "../models/User.model.js";
import { ApiError } from "./ApiError.js";
export 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 accessToken = user.generateAccessToken();
const refreshToken = user.generateRefreshToken();
// Store the refresh token in the database to manage active sessions
user.refreshToken = refreshToken;
await user.save({ validateBeforeSave: false });
return { accessToken, refreshToken };
} catch (err) {
throw new ApiError(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.
Step 4: Coding the Authentication Controllers
Now we implement our core application logic: User Registration, Login, and Session Management.
Shared Cookie Parameters
To guard against client-side script token theft, we enforce strict browser settings via cookie options:
const cookieOptions = {
httpOnly: true, // Prevents XSS script execution from reading the token
secure: process.env.NODE_ENV === "production", // Forces HTTPS connections in production
sameSite: "strict" as const, // Suppresses CSRF attacks across origins
};
import { type Request, type Response } from "express";
import { User } from "../models/User.model.js";
import { ApiError } from "../utils/ApiError.js";
import { ApiResponse } from "../utils/ApiResponse.js";
import { generateTokens } from "../utils/generateTokens.js";
// 1. REGISTER USER
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 registration fields are required");
}
const userExists = await User.findOne({ $or: [{ username }, { email }] });
if (userExists) {
throw new ApiError(409, "A user with this 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, "Something went wrong while creating the user account");
}
return res.status(201).json(
new ApiResponse(201, createdUser, "User registered successfully")
);
};
// 2. LOGIN USER
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 field is required");
}
const user = await User.findOne({ $or: [{ username }, { email }] });
if (!user) {
throw new ApiError(404, "User account does not exist");
}
const isPassValid = await user.isPasswordCorrect(password);
if (!isPassValid) {
throw new ApiError(401, "Invalid user credentials");
}
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")
);
};
// 3. REFRESH ACCESS TOKEN
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 any;
const user = await User.findById(decodedToken?._id);
if (!user) {
throw new ApiError(401, "Invalid refresh token: User context not found");
}
if (user.refreshToken !== incomingRefreshToken) {
throw new ApiError(401, "Refresh token has expired or been revoked");
}
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 signature");
}
};
// 4. LOGOUT USER
export const logoutUser = async (req: Request, res: Response) => {
const userId = req.user?._id;
if (!userId) {
throw new ApiError(400, "Authenticated user context required for logout");
}
// Clear the database reference token completely
await User.findByIdAndUpdate(userId, { $unset: { refreshToken: 1 } });
return res
.status(200)
.clearCookie("accessToken", cookieOptions)
.clearCookie("refreshToken", cookieOptions)
.json(new ApiResponse(200, {}, "User logged out successfully"));
};
Step 5: Building the Gatekeeper (Auth Middleware)
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";
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, "Authentication required: Access token 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, "Authentication failed: Revoked user authorization");
}
req.user = user;
next();
} catch (error: any) {
next(new ApiError(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.
Step 6: Setting Up Express Auth Routes
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).
src/routes/auth.routes.ts
import { Router } from "express";
import { loginUser, logoutUser, registerUser, refreshAccessToken } from "../controllers/auth.controller.js";
import { verifyJWT } from "../middlewares/auth.middleware.js";
const router = Router();
// Public Routes
router.route("/register").post(registerUser);
router.route("/login").post(loginUser);
router.route("/refresh-token").post(refreshAccessToken);
// Protected Routes (Uses middleware prior to hitting the controller)
router.route("/logout").post(verifyJWT, logoutUser);
export default router;
💡 Intermediate Tip: Streamlining Bulk Protected Routes
If you have dedicated route files where every single endpoint requires authentication (such asuser.routes.tsordashboard.routes.ts), passingverifyJWTindividually can lead to repetitive code. Instead, mount the middleware at the root layout of the router:const router = Router(); // Mount globally onto this specific router group instance router.use(verifyJWT); // Every endpoint listed down below is automatically secured! router.route("/profile").get(getUserProfile); router.route("/settings").patch(updateSettings);
---
## Step 7: Extending Express Typing Configurations
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:
json
{
"compilerOptions": {
"moduleResolution": "NodeNext",
"typeRoots": [
"./node_modules/@types",
"./src/@types"
]
},
"include": [
"src//*",
"src/@types//*.d.ts"
]
}
---
## Final Checklist
When building or updating your authentication service, ensure you check off these steps:
* <input type="checkbox" /> Explicitly clear sensitive properties (`-password`, `-refreshToken`) when retrieving items.
* <input type="checkbox" /> Passwords are automatically hashed via Mongoose hooks before persisting.
* <input type="checkbox" /> Cookies use `httpOnly: true` and strict `sameSite` setups.
* <input type="checkbox" /> Database validation is run after clearing the basic cryptographic token checks.
Happy coding! 🚀
Top comments (0)