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.
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.
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: falseon password — never returned in queries unless explicitly requestedcrypto.randomBytes(20)for reset tokens — cryptographically secure random- JWT with expiry — token auto-invalidates after 7 days
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:
- User POSTs email to /api/auth/forgot-password
- Server generates a cryptographically secure random token
- Server stores HASHED token in DB (never store raw token!)
- Server emails the RAW token as part of a reset link
- User clicks link, enters new password
- 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
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:
| Role | Permissions | Examples |
|---|---|---|
| customer | View menu, place orders, view own orders | Regular user ordering biryani |
| driver | Accept rides, start/complete rides | Auto-rickshaw driver |
| restaurant | Manage menu items, view orders for their restaurant | Biryani restaurant owner |
| admin | Everything — manage users, restaurants, all data | DevInHyderabad 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!
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:
| Feature | Endpoint | Auth |
|---|---|---|
| Register | POST /api/auth/register | None |
| Login | POST /api/auth/login | None |
| Forgot Password | POST /api/auth/forgot-password | None |
| Reset Password | PUT /api/auth/reset-password/:token | Reset Token |
| Get Profile | GET /api/auth/me | JWT |
| Update Details | PATCH /api/auth/updatedetails | JWT |
| Update Password | PATCH /api/auth/updatepassword | JWT |
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)
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login