DEV Community

Cover image for JWT Auth in Express with TS
NHero
NHero

Posted on • Originally published at nhero.me

JWT Auth in Express with TS

Project Structure

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

Install Required Packages

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:

ACCESS_TOKEN_SECRET=
ACCESS_TOKEN_EXPIRY=

REFRESH_TOKEN_SECRET=
REFRESH_TOKEN_EXPIRY=
Enter fullscreen mode Exit fullscreen mode

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

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

Why Use This Pattern?

Instead of manually writing responses like:

return res.status(200).json({
  success: true,
  message: "Logged in",
  data,
});
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

};
Enter fullscreen mode Exit fullscreen mode

Why Remove Password & Refresh Token?

.select("-password -refreshToken")
Enter fullscreen mode Exit fullscreen mode

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

Why Use Cookies Instead of LocalStorage?

We use:

httpOnly: true
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

tsconfig.json

{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./src/@types"
    ]
  },

  "include": [
    "src/**/*",
    "src/@types/**/*.d.ts"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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)