DEV Community

Cover image for Password Auth in Express with Typescript & JWT
NHero
NHero

Posted on • Originally published at nhero.me

Password Auth in Express with Typescript & JWT

⚡ 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]

Enter fullscreen mode Exit fullscreen mode

Project Setup & Prerequisites

📂 Project Structure

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

Enter fullscreen mode Exit fullscreen mode

📥 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

Enter fullscreen mode Exit fullscreen mode

🌐 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

Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
}

Enter fullscreen mode Exit fullscreen mode
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
  }
}

Enter fullscreen mode Exit fullscreen mode

💡 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.


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);

Enter fullscreen mode Exit fullscreen mode

🧠 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");
  }
};

Enter fullscreen mode Exit fullscreen mode

🔒 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
};

Enter fullscreen mode Exit fullscreen mode
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"));
};

Enter fullscreen mode Exit fullscreen mode

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"));
  }
};

Enter fullscreen mode Exit fullscreen mode

🔒 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;

Enter fullscreen mode Exit fullscreen mode

💡 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:

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`
Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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! 🚀
Enter fullscreen mode Exit fullscreen mode

Top comments (0)