Chapter 5.4☕ 25 min read

Project 4: Auth System — Full JWT Flow & RBAC

Auth is the backbone of every web app. Build a production-ready auth system with registration, login, JWT tokens, forget password, and role-based access control.

01Project Overview — Complete Auth Architecture

Authentication is the most critical feature of any web application. In this project, we'll build a complete auth system that can be used in any Node.js app — from Swiggy to Uber to your own projects.

What we'll build:

  • Registration — User signup with password hashing (bcryptjs)
  • Login — Email/password verification + JWT generation
  • Forgot Password — Generate reset token, send email with nodemailer
  • Protected Routes — JWT middleware to protect API endpoints
  • Role-Based Access Control (RBAC) — Admin vs Customer vs Driver roles
  • Refresh Tokens — Optional long-lived tokens for better security

Auth architecture:

POST /api/auth/register   → Creates user, returns JWT
POST /api/auth/login      → Verifies credentials, returns JWT
POST /api/auth/forgot-password → Sends reset link email
POST /api/auth/reset-password  → Resets password with token
GET  /api/auth/me         → Returns current user (protected)
PATCH /api/auth/updatedetails → Update profile (protected)
PATCH /api/auth/updatepassword → Change password (protected)

By the end of this project, you'll have a complete auth system you can copy into ANY Node.js project. This is the same auth pattern used by production apps.

02Register & Login — JWT Token Flow

Let's build the core auth endpoints — registration and login. These use bcryptjs for password hashing and jsonwebtoken for token generation.

User Model with roles:

// models/User.js — Full User Schema
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

const userSchema = new mongoose.Schema({
  name: { type: String, required: [true, 'Name daalo bhai!'], trim: true, maxlength: 50 },
  email: {
    type: String,
    required: [true, 'Email daalo bhai!'],
    unique: true,
    lowercase: true,
    match: [/^\S+@\S+\.\S+$/, 'Sahi email daalo!']
  },
  password: {
    type: String,
    required: [true, 'Password daalo bhai!'],
    minlength: [6, 'Password 6 chars se chota nahi hona chahiye'],
    select: false
  },
  role: {
    type: String,
    enum: ['customer', 'admin', 'driver', 'restaurant'],
    default: 'customer'
  },
  phone: { type: String },
  resetPasswordToken: String,
  resetPasswordExpire: Date
}, { timestamps: true });

// Hash password before saving
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  const salt = await bcrypt.genSalt(12);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

// Compare entered password with hashed password
userSchema.methods.matchPassword = async function(enteredPassword) {
  return await bcrypt.compare(enteredPassword, this.password);
};

// Generate signed JWT
userSchema.methods.generateToken = function() {
  return jwt.sign(
    { id: this._id, email: this.email, role: this.role },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
  );
};

// Generate password reset token
userSchema.methods.generateResetToken = function() {
  const resetToken = crypto.randomBytes(20).toString('hex');
  this.resetPasswordToken = crypto.createHash('sha256').update(resetToken).digest('hex');
  this.resetPasswordExpire = Date.now() + 60 * 60 * 1000; // 1 hour
  return resetToken;
};

export default mongoose.model('User', userSchema);

Auth Controller — Register & Login:

// controllers/auth.controller.js — Register & Login
import User from '../models/User.js';
import AppError from '../utils/AppError.js';
import asyncHandler from '../utils/asyncHandler.js';

// POST /api/auth/register
export const register = asyncHandler(async (req, res) => {
  const { name, email, password, role } = req.body;

  // Check if user exists
  const existingUser = await User.findOne({ email });
  if (existingUser) {
    throw new AppError('User already exists! Yeh email already registered hai bhai!', 400);
  }

  // Create user (password is hashed by pre-save hook)
  const user = await User.create({ name, email, password, role });

  // Generate JWT
  const token = user.generateToken();

  res.status(201).json({
    success: true,
    message: 'Registration successful! Swagat hai bhai! 🎉',
    token,
    user: { id: user._id, name: user.name, email: user.email, role: user.role }
  });
});

// POST /api/auth/login
export const login = asyncHandler(async (req, res, next) => {
  const { email, password } = req.body;

  if (!email || !password) {
    throw new AppError('Email aur password dono daalo bhai!', 400);
  }

  // Find user + explicitly select password
  const user = await User.findOne({ email }).select('+password');
  if (!user) {
    throw new AppError('Invalid credentials! Email ya password galat hai!', 401);
  }

  // Check password
  const isMatch = await user.matchPassword(password);
  if (!isMatch) {
    throw new AppError('Invalid credentials! Email ya password galat hai!', 401);
  }

  const token = user.generateToken();

  res.json({
    success: true,
    message: 'Login successful! Biryani order kar sakte ho! 🍔',
    token,
    user: { id: user._id, name: user.name, email: user.email, role: user.role }
  });
});

// GET /api/auth/me — Get current logged-in user (protected)
export const getMe = asyncHandler(async (req, res) => {
  const user = await User.findById(req.user.id);
  res.json({ success: true, data: user });
});

Key security features:

  • Password auto-hashed via Mongoose pre('save') hook — you never see the plain password in your code
  • select: false on password — never returned in queries unless explicitly requested
  • crypto.randomBytes(20) for reset tokens — cryptographically secure random
  • JWT with expiry — token auto-invalidates after 7 days
03Forgot Password — Email Reset Link

Users forget passwords. The forgot password flow sends a reset link to the user's email. This is a critical feature for any user-facing application.

Flow:

  1. User POSTs email to /api/auth/forgot-password
  2. Server generates a cryptographically secure random token
  3. Server stores HASHED token in DB (never store raw token!)
  4. Server emails the RAW token as part of a reset link
  5. User clicks link, enters new password
  6. Server verifies token hash matches DB, updates password
// controllers/auth.controller.js — Forgot & Reset Password
import crypto from 'crypto';
import { sendEmail } from '../services/email.service.js';

// POST /api/auth/forgot-password
export const forgotPassword = asyncHandler(async (req, res, next) => {
  const user = await User.findOne({ email: req.body.email });
  if (!user) {
    // Don't reveal whether email exists — security best practice
    return res.json({ success: true, message: 'If that email exists, a reset link has been sent! 📧' });
  }

  // Get reset token (raw)
  const resetToken = user.generateResetToken();
  await user.save({ validateBeforeSave: false });

  // Create reset URL
  const resetUrl = process.env.APP_URL + '/reset-password/' + resetToken;

  // Send email
  try {
    await sendEmail({
      to: user.email,
      subject: '🔑 Password Reset - DevInHyderabad',
      html: '
' + '

Password Reset 🔑

' + '

Hey ' + user.name + '! 👋

' + '

Click the button below to reset your password. This link expires in 1 hour.

' + 'Reset Password' + '

If you didn't request this, ignore this email.

' + '

DevInHyderabad — Your security matters! 🔒

' }); res.json({ success: true, message: 'Reset link sent to email! 📧' }); } catch (err) { user.resetPasswordToken = undefined; user.resetPasswordExpire = undefined; await user.save({ validateBeforeSave: false }); throw new AppError('Email could not be sent! Phirse try karo bhai!', 500); } }); // PUT /api/auth/reset-password/:token export const resetPassword = asyncHandler(async (req, res, next) => { // Hash the incoming token const hashedToken = crypto.createHash('sha256').update(req.params.token).digest('hex'); const user = await User.findOne({ resetPasswordToken: hashedToken, resetPasswordExpire: { $gt: Date.now() } }); if (!user) { throw new AppError('Invalid or expired reset token! Link khatam ho gaya ya galat hai! ⏰', 400); } // Set new password (pre-save hook will hash it) user.password = req.body.password; user.resetPasswordToken = undefined; user.resetPasswordExpire = undefined; await user.save(); // Generate new JWT so user is logged in after reset const token = user.generateToken(); res.json({ success: true, message: 'Password reset successful! Naye password se login karo! 🔑', token }); });

Security best practices in forgot password:

  • Never reveal if email exists — Always show "If that email exists, a link has been sent" to prevent email enumeration attacks
  • Store HASHED token, not raw — If DB is breached, attacker can't generate valid reset links
  • Token expiry — 1 hour expiration limits attack window
  • Clear token after use — Prevent reuse of the same token
  • Error handling — If email fails, clear the token so user can request again
04Role-Based Access Control — Admin vs Customer

Role-Based Access Control (RBAC) is the practice of restricting system access based on a user's role. Different roles have different permissions — like a Swiggy admin who can manage all restaurants vs a regular customer who can only order food.

Our roles:

RolePermissionsExamples
customerView menu, place orders, view own ordersRegular user ordering biryani
driverAccept rides, start/complete ridesAuto-rickshaw driver
restaurantManage menu items, view orders for their restaurantBiryani restaurant owner
adminEverything — manage users, restaurants, all dataDevInHyderabad team

Auth middleware with RBAC:

// middleware/auth.middleware.js — JWT Auth + RBAC
import jwt from 'jsonwebtoken';
import User from '../models/User.js';
import AppError from '../utils/AppError.js';

// Protect middleware — verifies JWT, attaches user to req
export const protect = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new AppError('No token! Pehle login karo bhai! 🎫', 401);
    }

    const token = authHeader.split(' ')[1];
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Get full user from DB (not just decoded token)
    const user = await User.findById(decoded.id);
    if (!user) {
      throw new AppError('User not found! Yeh user exist nahi karta!', 401);
    }

    req.user = { id: user._id, email: user.email, role: user.role, name: user.name };
    next();
  } catch (error) {
    if (error.name === 'JsonWebTokenError') {
      return next(new AppError('Invalid token! JWT mein gadbad hai! ❌', 401));
    }
    if (error.name === 'TokenExpiredError') {
      return next(new AppError('Token expired! Phirse login karo bhai! ⏰', 401));
    }
    next(error);
  }
};

// Authorize middleware — restricts by role(s)
// Usage: router.delete('/:id', protect, authorize('admin'), handler)
export const authorize = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      throw new AppError(
        req.user.role + ' ke paas permission nahi hai bhai! Sirf ' + roles.join(', ') + ' kar sakte hain 🚫',
        403
      );
    }
    next();
  };
};

Using RBAC in routes:

// routes/biryani.routes.js — RBAC in Action
import { protect, authorize } from '../middleware/auth.middleware.js';

// Public — anyone can see the menu
router.get('/', getAllBiryanis);

// Customer — only logged-in users can order
router.post('/orders', protect, placeOrder);

// Restaurant — only restaurant owners can add menu items
router.post('/menu', protect, authorize('restaurant', 'admin'), addMenuItem);

// Admin only — critical operations
router.delete('/:id', protect, authorize('admin'), deleteBiryani);

// Mixed roles
router.put('/:id', protect, authorize('restaurant', 'admin'), updateBiryani);

// Never trust client-side role data!
// ❌ BAD: const { role } = req.body; user.role = role;
// ✅ GOOD: role comes from JWT (signed by server)

Critical rule: NEVER trust client-side role data! The user's role must come from the JWT (which is signed by your server), NOT from the request body. If you read req.body.role, a malicious user could register as { role: "admin" } and gain admin access!

05Complete Auth Flow — All Endpoints Working Together

Let's wire everything together — the complete auth flow from registration to role-protected endpoints.

Auth routes:

// routes/auth.routes.js — Complete Auth Routes
import express from 'express';
import {
  register, login, getMe,
  forgotPassword, resetPassword,
  updateDetails, updatePassword
} from '../controllers/auth.controller.js';
import { protect } from '../middleware/auth.middleware.js';
import { validateRegister, validateLogin } from '../middleware/validation.middleware.js';

const router = express.Router();

// Public routes (no auth needed)
router.post('/register', validateRegister, register);
router.post('/login', validateLogin, login);
router.post('/forgot-password', forgotPassword);
router.put('/reset-password/:token', resetPassword);

// Protected routes (must be logged in)
router.get('/me', protect, getMe);
router.patch('/updatedetails', protect, updateDetails);
router.patch('/updatepassword', protect, updatePassword);

export default router;

Complete auth flow test:

# 1. Register as customer
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Ravi", "email": "ravi@test.com", "password": "abc123", "role": "customer"}'

# Response includes JWT token — save it!

# 2. Login
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "ravi@test.com", "password": "abc123"}'

# 3. Access protected route (get my profile)
curl http://localhost:3000/api/auth/me \
  -H "Authorization: Bearer "

# 4. Register as admin (from admin dashboard)
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Admin", "email": "admin@test.com", "password": "admin123", "role": "admin"}'

# 5. Test RBAC — admin can delete, customer can't
curl -X DELETE http://localhost:3000/api/biryanis/abc \
  -H "Authorization: Bearer "  # → 403 Forbidden!
curl -X DELETE http://localhost:3000/api/biryanis/abc \
  -H "Authorization: Bearer "      # → 200 OK!

What we built — complete auth feature set:

FeatureEndpointAuth
RegisterPOST /api/auth/registerNone
LoginPOST /api/auth/loginNone
Forgot PasswordPOST /api/auth/forgot-passwordNone
Reset PasswordPUT /api/auth/reset-password/:tokenReset Token
Get ProfileGET /api/auth/meJWT
Update DetailsPATCH /api/auth/updatedetailsJWT
Update PasswordPATCH /api/auth/updatepasswordJWT

This auth system is production-ready and can be dropped into ANY Node.js/Express project!

Key Takeaways

  • ✅ Complete auth system: register, login, forgot/reset password, profile management
  • ✅ password hashing via Mongoose pre-save hook — never store plain text
  • ✅ JWT tokens with expiry — stateless auth, no server-side sessions needed
  • ✅ Forgot password: store HASHED reset token in DB, email RAW token as link
  • ✅ RBAC: protect() verifies JWT, authorize('admin') checks role — both as middleware
  • ✅ NEVER trust client-supplied role data — role must come from signed JWT
  • ✅ Use crypto.randomBytes() for secure token generation, crypto.createHash() for hashing
  • ✅ Don't reveal if email exists in forgot-password (prevents email enumeration)
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase