DocsHub
Back-EndSchemas

User Schema

A complete Mongoose User schema with password hashing, comparison method, and JWT-ready fields — used as the base for every project.

User Schema

Almost every backend needs a User model. This is the base version used throughout this section — with password hashing built in, a method to compare passwords on login, and fields ready for JWT authentication and role-based access.


Full Schema

// src/models/user.model.js
import mongoose from "mongoose";
import bcrypt from "bcryptjs";

const userSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: [true, "Name is required"],
      trim: true,
    },
    email: {
      type: String,
      required: [true, "Email is required"],
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"],
    },
    password: {
      type: String,
      required: [true, "Password is required"],
      minlength: [8, "Password must be at least 8 characters"],
      select: false, // never returned in queries unless explicitly requested
    },
    role: {
      type: String,
      enum: ["user", "admin"],
      default: "user",
    },
    avatar: {
      type: String,
      default: "",
    },
    isEmailVerified: {
      type: Boolean,
      default: false,
    },
    refreshToken: {
      type: String,
      select: false, // used for JWT refresh token rotation — never exposed
    },
    passwordResetToken: {
      type: String,
      select: false,
    },
    passwordResetExpires: {
      type: Date,
      select: false,
    },
  },
  { timestamps: true }
);

// Step 1 — hash the password before saving, only if it was modified
// this runs automatically on .save() — not on .updateOne() or findOneAndUpdate()
userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) return next();

  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

// Step 2 — instance method to compare a plain password against the hashed one
// used during login
userSchema.methods.comparePassword = async function (candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

// Step 3 — instance method to return a safe version of the user
// excludes sensitive fields even if they were accidentally selected
userSchema.methods.toSafeObject = function () {
  return {
    id: this._id,
    name: this.name,
    email: this.email,
    role: this.role,
    avatar: this.avatar,
    isEmailVerified: this.isEmailVerified,
    createdAt: this.createdAt,
  };
};

const User = mongoose.model("User", userSchema);

export default User;

How Each Part Is Used

Creating a user — password hashes automatically

import User from "../models/user.model.js";

const user = await User.create({
  name: "Ali Hassan",
  email: "ali@example.com",
  password: "plaintext123", // gets hashed by the pre-save hook automatically
});

Login — comparing passwords

// password has select: false, so it must be explicitly requested
const user = await User.findOne({ email }).select("+password");

if (!user) {
  throw new Error("Invalid credentials");
}

const isMatch = await user.comparePassword(candidatePassword);

if (!isMatch) {
  throw new Error("Invalid credentials");
}

// safe to send back to the client
const safeUser = user.toSafeObject();

Why select: false matters

// without select: false — password leaks in every query
const users = await User.find(); // ❌ includes hashed passwords

// with select: false — password is hidden by default
const users = await User.find(); // ✅ no password field at all

// explicitly include it only when needed (like login)
const user = await User.findOne({ email }).select("+password"); // ✅

pre("save") only runs on .save() and .create(). If you ever update a password using findOneAndUpdate(), the hashing hook will NOT run — the password will be saved in plain text. Always use .save() for password updates, or hash manually before using update methods.


Updating a Password Safely

// ✅ correct — triggers the pre-save hook
const user = await User.findById(userId);
user.password = "newpassword123";
await user.save(); // hashing hook runs here

// ❌ wrong — bypasses the hashing hook entirely
await User.findByIdAndUpdate(userId, { password: "newpassword123" });
// password is now stored in plain text — security risk

Summary

  • password field uses select: false so it is never returned by default — must be explicitly requested with .select("+password")
  • pre("save") hook hashes the password automatically using bcrypt — only runs when the password field is modified
  • comparePassword() instance method handles login verification using bcrypt.compare()
  • toSafeObject() returns a clean version of the user with no sensitive fields — use this whenever sending user data to the frontend
  • Always use .save() for password changes — findOneAndUpdate() skips the hashing hook
  • refreshToken, passwordResetToken, and passwordResetExpires fields are ready for the authentication flows covered later in this section

On this page