{"slug": "jwt-auth-in-express-with-ts", "title": "JWT Auth in Express with TS", "summary": "This article provides a tutorial on implementing JWT authentication in an Express.js application using TypeScript, covering the setup of environment variables, custom helper classes for standardized API responses and error handling, and a Mongoose user model with built-in methods for password hashing and token generation. The tutorial emphasizes best practices such as using both access and refresh tokens, attaching authentication methods directly to the Mongoose schema, and using bcrypt for secure password storage.", "body_md": "## ⚡ Quick Architecture Cheat Sheet (For Fast Revision)\n\nIf you are using this post to refresh your memory, here is the core token blueprint:\n\n| Token Type | Stored In | Lifetime (Recommended) | Primary Purpose |\n|---|---|---|---|\nAccess Token |\nHTTP-Only Cookie / Auth Header | 15 Minutes | Authenticating short-lived protected route requests |\nRefresh Token |\nDatabase & HTTP-Only Cookie | 7 to 10 Days | Requesting a brand new Access Token when it expires |\n\n### The Token Lifecycle Flow\n\n``` php\n[Client] -------------- 1. Send Login Credentials ---------------> [Backend]\n[Client] <-------- 2. Set Access & Refresh Cookies --------------- [Backend] (Saves Refresh Token to DB)\n\n[Client] -------- 3. Access Protected Route (With Cookie) --------> [verifyJWT Middleware] -> [Granted]\n```\n\n## Project Setup & Prerequisites\n\n### 📂 Project Structure\n\n```\n└── src/\n    ├── @types/\n    ├── controllers/\n    ├── middlewares/\n    ├── models/\n    ├── routes/\n    ├── utils/\n    ├── app.ts\n    └── index.ts\n```\n\n### 📥 Install Required Packages\n\nRun the following commands to install the necessary production and development dependencies:\n\n```\nnpm install jsonwebtoken bcrypt mongoose cookie-parser\nnpm install -D @types/jsonwebtoken @types/bcrypt\n```\n\n### 🌐 Environment Variables\n\nCreate a `.env`\n\nfile in your root directory:\n\n```\nACCESS_TOKEN_SECRET=your_super_secret_access_key\nACCESS_TOKEN_EXPIRY=15m\n\nREFRESH_TOKEN_SECRET=your_super_secret_refresh_key\nREFRESH_TOKEN_EXPIRY=7d\n```\n\n## Step 1: Standardizing Global API Responses\n\nBefore touching authentication, we build reusable helper classes. This keeps frontend handling much cleaner since every response follows the same structure.\n\n```\nexport class ApiError extends Error {\n  statusCode: number;\n  message: string;\n  errors: any[];\n  stack?: string;\n  data: any;\n  success: boolean;\n\n  constructor(\n    statusCode: number,\n    message = \"Something went wrong\",\n    errors = [],\n    stack = \"\"\n  ) {\n    super(message)\n    this.statusCode = statusCode\n    this.data = null\n    this.message = message\n    this.success = false\n    this.errors = errors\n\n    if (stack) {\n      this.stack = stack\n    } else {\n      Error.captureStackTrace(this, this.constructor)\n    }\n  }\n}\nexport class ApiResponse {\n  statusCode: number;\n  data: any;\n  message: string;\n  success: boolean;\n\n  constructor(\n    statusCode: number,\n    data: any,\n    message: string = \"Success\"\n  ) {\n    this.statusCode = statusCode\n    this.data = data\n    this.message = message\n    this.success = statusCode < 400\n  }\n}\n```\n\n💡\n\nWhy use this pattern?\n\nInstead of manually typing`return res.status(200).json({ success: true, data })`\n\nin dozens of locations, these utility classes enforce structural consistency across your entire backend footprint.\n\n## Step 2: Designing the Data Layer (The User Model)\n\nInstead of hashing passwords manually inside controllers every time, we move the logic into the schema itself using pre-save hooks and custom schema methods.\n\n``` python\nimport { model, Schema, type HydratedDocument } from \"mongoose\";\nimport jwt, { type Secret, type SignOptions } from \"jsonwebtoken\";\nimport bcrypt from \"bcrypt\";\n\nexport interface IUser {\n  username: string;\n  fullName: string;\n  email: string;\n  password: string;\n  avatarUrl?: string;\n  refreshToken?: string;\n\n  generateAccessToken: () => string;\n  generateRefreshToken: () => string;\n  isPasswordCorrect: (password: string) => Promise<boolean>;\n}\n\nexport type IUserDocument = HydratedDocument<IUser>;\n\nconst userSchema = new Schema<IUser>({\n  username: { type: String, required: true, unique: true, lowercase: true },\n  fullName: { type: String, required: true },\n  email: { type: String, required: true, unique: true },\n  password: { type: String, required: true },\n  avatarUrl: { type: String },\n  refreshToken: { type: String }\n});\n\n// Automatic Password Hashing Hook\nuserSchema.pre(\"save\", async function (next): Promise<void> {\n  if (!this.isModified(\"password\")) return next();\n\n  this.password = await bcrypt.hash(this.password, 10);\n  next();\n});\n\n// Secure Password Comparison Method\nuserSchema.methods.isPasswordCorrect = async function (password: string): Promise<boolean> {\n  return await bcrypt.compare(password, this.password);\n};\n\n// Access Token Generator\nuserSchema.methods.generateAccessToken = function (): string {\n  return jwt.sign(\n    {\n      _id: this._id,\n      email: this.email,\n      username: this.username,\n      fullName: this.fullName,\n    },\n    process.env.ACCESS_TOKEN_SECRET as Secret,\n    {\n      expiresIn: process.env.ACCESS_TOKEN_EXPIRY,\n    } as SignOptions\n  );\n};\n\n// Refresh Token Generator\nuserSchema.methods.generateRefreshToken = function (): string {\n  return jwt.sign(\n    {\n      _id: this._id,\n    },\n    process.env.REFRESH_TOKEN_SECRET as Secret,\n    {\n      expiresIn: process.env.REFRESH_TOKEN_EXPIRY,\n    } as SignOptions\n  );\n};\n\nexport const User = model<IUser>(\"User\", userSchema);\n```\n\n🧠\n\nRevision Insight: Why handle Hashing & Token generation in the Model?\n\nAutomatic Hashing:The`pre(\"save\")`\n\nhook ensures you can never accidentally save a plaintext password to your database.\n\nEncapsulation:Controllers don't need to know how JWTs are signed or how passwords are encrypted. They simply call`user.generateAccessToken()`\n\n.\n\n## Step 3: Centralizing Token Orchestration\n\nInstead 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.\n\n`src/utils/generateTokens.ts`\n\n``` js\nimport { Types } from \"mongoose\";\nimport { User } from \"../models/User.model.js\";\nimport { ApiError } from \"./ApiError.js\";\n\nexport const generateTokens = async (userId: Types.ObjectId | string): Promise<{ accessToken: string; refreshToken: string }> => {\n  try {\n    const user = await User.findById(userId);\n    if (!user) {\n      throw new ApiError(404, \"User not found\");\n    }\n\n    const accessToken = user.generateAccessToken();\n    const refreshToken = user.generateRefreshToken();\n\n    // Store the refresh token in the database to manage active sessions\n    user.refreshToken = refreshToken;\n    await user.save({ validateBeforeSave: false });\n\n    return { accessToken, refreshToken };\n  } catch (err) {\n    throw new ApiError(500, \"Error while generating authentication tokens\");\n  }\n};\n```\n\n🔒\n\nSecurity Checkpoint: Why save the Refresh Token to the database?\n\nStoring refresh tokens statefully allows the backend to force a hard logout, instantly invalidate stolen sessions, and roll out security practices like refresh token rotation.\n\n## Step 4: Coding the Authentication Controllers\n\nNow we implement our core application logic: User Registration, Login, and Session Management.\n\n### Shared Cookie Parameters\n\nTo guard against client-side script token theft, we enforce strict browser settings via cookie options:\n\n``` js\nconst cookieOptions = {\n  httpOnly: true, // Prevents XSS script execution from reading the token\n  secure: process.env.NODE_ENV === \"production\", // Forces HTTPS connections in production\n  sameSite: \"strict\" as const, // Suppresses CSRF attacks across origins\n};\njs\nimport { type Request, type Response } from \"express\";\nimport { User } from \"../models/User.model.js\";\nimport { ApiError } from \"../utils/ApiError.js\";\nimport { ApiResponse } from \"../utils/ApiResponse.js\";\nimport { generateTokens } from \"../utils/generateTokens.js\";\n\n// 1. REGISTER USER\nexport const registerUser = async (req: Request, res: Response) => {\n  const { username, email, fullName, password } = req.body;\n\n  if ([fullName, email, username, password].some((field) => field?.trim() === \"\")) {\n    throw new ApiError(400, \"All registration fields are required\");\n  }\n\n  const userExists = await User.findOne({ $or: [{ username }, { email }] });\n  if (userExists) {\n    throw new ApiError(409, \"A user with this username or email already exists\");\n  }\n\n  const user = await User.create({\n    fullName,\n    email,\n    password,\n    username: username.toLowerCase(),\n  });\n\n  const createdUser = await User.findById(user._id).select(\"-password -refreshToken\");\n  if (!createdUser) {\n    throw new ApiError(500, \"Something went wrong while creating the user account\");\n  }\n\n  return res.status(201).json(\n    new ApiResponse(201, createdUser, \"User registered successfully\")\n  );\n};\n\n// 2. LOGIN USER\nexport const loginUser = async (req: Request, res: Response) => {\n  const { username, email, password } = req.body;\n\n  if (!username && !email) {\n    throw new ApiError(400, \"Username or email is required\");\n  } \n  if (!password) {\n    throw new ApiError(400, \"Password field is required\");\n  }\n\n  const user = await User.findOne({ $or: [{ username }, { email }] });\n  if (!user) {\n    throw new ApiError(404, \"User account does not exist\");\n  }\n\n  const isPassValid = await user.isPasswordCorrect(password);\n  if (!isPassValid) {\n    throw new ApiError(401, \"Invalid user credentials\");\n  }\n\n  const { accessToken, refreshToken } = await generateTokens(user._id);\n\n  const userData = {\n    _id: user._id,\n    fullName: user.fullName,\n    username: user.username,\n    email: user.email,\n    avatarUrl: user.avatarUrl,\n  };\n\n  return res\n    .status(200)\n    .cookie(\"accessToken\", accessToken, cookieOptions)\n    .cookie(\"refreshToken\", refreshToken, cookieOptions)\n    .json(\n      new ApiResponse(200, { user: userData, accessToken, refreshToken }, \"User logged in successfully\")\n    );\n};\n\n// 3. REFRESH ACCESS TOKEN\nexport const refreshAccessToken = async (req: Request, res: Response) => {\n  const incomingRefreshToken = req.cookies.refreshToken || req.body.refreshToken;\n\n  if (!incomingRefreshToken) {\n    throw new ApiError(401, \"Refresh token is missing\");\n  }\n\n  try {\n    const decodedToken = jwt.verify(incomingRefreshToken, process.env.REFRESH_TOKEN_SECRET!) as any;\n    const user = await User.findById(decodedToken?._id);\n\n    if (!user) {\n      throw new ApiError(401, \"Invalid refresh token: User context not found\");\n    }\n\n    if (user.refreshToken !== incomingRefreshToken) { \n      throw new ApiError(401, \"Refresh token has expired or been revoked\");\n    }\n\n    const { accessToken, refreshToken: newRefreshToken } = await generateTokens(user._id);\n\n    return res\n      .status(200)\n      .cookie(\"accessToken\", accessToken, cookieOptions)\n      .cookie(\"refreshToken\", newRefreshToken, cookieOptions)\n      .json(\n        new ApiResponse(200, { accessToken, refreshToken: newRefreshToken }, \"Access token refreshed successfully\")\n      );\n  } catch (err: any) {\n    throw new ApiError(401, err?.message || \"Invalid refresh token signature\");\n  }\n};\n\n// 4. LOGOUT USER\nexport const logoutUser = async (req: Request, res: Response) => {\n  const userId = req.user?._id;\n\n  if (!userId) {\n    throw new ApiError(400, \"Authenticated user context required for logout\");\n  }\n\n  // Clear the database reference token completely\n  await User.findByIdAndUpdate(userId, { $unset: { refreshToken: 1 } });  \n\n  return res\n    .status(200)\n    .clearCookie(\"accessToken\", cookieOptions)  \n    .clearCookie(\"refreshToken\", cookieOptions)\n    .json(new ApiResponse(200, {}, \"User logged out successfully\"));\n};\n```\n\n## Step 5: Building the Gatekeeper (Auth Middleware)\n\nAny 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.\n\n`src/middlewares/auth.middleware.ts`\n\n``` js\nimport { type NextFunction, type Request, type Response } from \"express\";\nimport { User } from \"../models/User.model.js\";\nimport { ApiError } from \"../utils/ApiError.js\";\nimport jwt, { type JwtPayload } from \"jsonwebtoken\";\n\nexport const verifyJWT = async (req: Request, _: Response, next: NextFunction) => {\n  try {\n    const accessToken = req.cookies?.accessToken || req.header(\"Authorization\")?.replace(\"Bearer \", \"\");\n\n    if (!accessToken) {\n      throw new ApiError(401, \"Authentication required: Access token missing\");\n    }\n\n    const decodedToken = jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET!) as JwtPayload;\n    const user = await User.findById(decodedToken._id).select(\"-password -refreshToken\");\n\n    if (!user) {\n      throw new ApiError(401, \"Authentication failed: Revoked user authorization\");\n    }\n\n    req.user = user;\n    next();\n  } catch (error: any) {\n    next(new ApiError(401, error?.message || \"Invalid or expired access token\"));\n  }\n};\n```\n\n🔒\n\nSecurity Checkpoint: Why verify against the Database?\n\nA 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.\n\n## Step 6: Setting Up Express Auth Routes\n\nNow, let's assemble our system components within our routing configurations. By chaining our controllers, we inject our `verifyJWT`\n\ngatekeeper directly in front of endpoints that demand active session contexts (like `/logout`\n\n).\n\n`src/routes/auth.routes.ts`\n\n``` js\nimport { Router } from \"express\";\nimport { loginUser, logoutUser, registerUser, refreshAccessToken } from \"../controllers/auth.controller.js\";\nimport { verifyJWT } from \"../middlewares/auth.middleware.js\";\n\nconst router = Router();\n\n// Public Routes\nrouter.route(\"/register\").post(registerUser);\nrouter.route(\"/login\").post(loginUser);\nrouter.route(\"/refresh-token\").post(refreshAccessToken);\n\n// Protected Routes (Uses middleware prior to hitting the controller)\nrouter.route(\"/logout\").post(verifyJWT, logoutUser);\n\nexport default router;\n```\n\n💡\n\nIntermediate Tip: Streamlining Bulk Protected Routes\n\nIf you have dedicated route files whereevery single endpointrequires authentication (such as`user.routes.ts`\n\nor`dashboard.routes.ts`\n\n), passing`verifyJWT`\n\nindividually can lead to repetitive code. Instead, mount the middleware at the root layout of the router:\n\n``` js\nconst router = Router();\n\n// Mount globally onto this specific router group instance\nrouter.use(verifyJWT);\n\n// Every endpoint listed down below is automatically secured!\nrouter.route(\"/profile\").get(getUserProfile);\nrouter.route(\"/settings\").patch(updateSettings);\n---\n\n## Step 7: Extending Express Typing Configurations\n\nBy 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.\n\n`src/@types/express/index.d.ts`\n```\n\nts\n\nimport { type IUserDocument } from \"../../models/User.model.js\";\n\ndeclare module \"express-serve-static-core\" {\n\ninterface Request {\n\nuser?: IUserDocument;\n\n}\n\n}\n\nexport {};\n\n```\nMake sure your `tsconfig.json` knows where to compile and read your local definitions files:\n```\n\njson\n\n{\n\n\"compilerOptions\": {\n\n\"moduleResolution\": \"NodeNext\",\n\n\"typeRoots\": [\n\n\"./node_modules/@types\",\n\n\"./src/@types\"\n\n]\n\n},\n\n\"include\": [\n\n\"src/**/*\",\n\"src/@types/**/*.d.ts\"\n\n]\n\n}\n\n```\n---\n\n## Final Checklist\n\nWhen building or updating your authentication service, ensure you check off these steps:\n\n* <input type=\"checkbox\" /> Explicitly clear sensitive properties (`-password`, `-refreshToken`) when retrieving items.\n\n* <input type=\"checkbox\" /> Passwords are automatically hashed via Mongoose hooks before persisting.\n\n* <input type=\"checkbox\" /> Cookies use `httpOnly: true` and strict `sameSite` setups.\n\n* <input type=\"checkbox\" /> Database validation is run after clearing the basic cryptographic token checks.\n\nHappy coding! 🚀\n```\n\n", "url": "https://wpnews.pro/news/jwt-auth-in-express-with-ts", "canonical_source": "https://dev.to/nhero/jwt-auth-in-express-with-ts-5egk", "published_at": "2026-05-23 20:51:54+00:00", "updated_at": "2026-05-23 21:01:16.021400+00:00", "lang": "en", "topics": ["developer-tools", "cybersecurity", "open-source"], "entities": ["Express", "TypeScript", "JWT", "bcrypt", "mongoose", "cookie-parser"], "alternates": {"html": "https://wpnews.pro/news/jwt-auth-in-express-with-ts", "markdown": "https://wpnews.pro/news/jwt-auth-in-express-with-ts.md", "text": "https://wpnews.pro/news/jwt-auth-in-express-with-ts.txt", "jsonld": "https://wpnews.pro/news/jwt-auth-in-express-with-ts.jsonld"}}