Build a secure authentication and authorization system from scratch! Learn JWT tokens, password hashing, protected routes, and role-based access control.
- Password Security - Hash passwords with bcrypt (never store plain text!)
- JWT Tokens - Generate and validate JSON Web Tokens
- Authentication - Verify user identity ("Who are you?")
- Authorization - Check permissions ("What can you do?")
- Protected Routes - Guard endpoints with middleware
- Role-Based Access Control (RBAC) - User vs Admin permissions
- Security Best Practices - Error messages, token storage, validation
npm install# Start MongoDB container
docker-compose up -d
# Check if MongoDB is running
docker-compose ps
# View MongoDB logs
docker-compose logs mongodbcp .env.example .envEdit .env and set your JWT secret:
JWT_SECRET=your-super-secret-key-change-in-production-minimum-32-characters
# Run all tests
npm test
# Run specific test
npm test -- 01-health
npm test -- 02-connect
npm test -- 03-register
npm test -- 04-login
npm test -- 05-protected
npm test -- 06-rolesnpm run devauth-api/
├── src/
│ ├── app.js # Express app setup
│ ├── server.js # Server startup
│ ├── db/
│ │ └── connect.js # MongoDB connection
│ ├── models/
│ │ └── user.model.js # User schema with password hashing
│ ├── controllers/
│ │ ├── auth.controller.js # Register, login, me
│ │ └── user.controller.js # User management (admin)
│ ├── routes/
│ │ ├── auth.routes.js # Auth endpoints
│ │ └── user.routes.js # User management endpoints
│ ├── middlewares/
│ │ ├── auth.middleware.js # JWT verification
│ │ ├── role.middleware.js # Role checking
│ │ ├── error.middleware.js # Error handling
│ │ └── notFound.middleware.js # 404 handling
│ └── utils/
│ └── jwt.js # JWT helpers (TODO - signToken, verifyToken)
└── tests/
├── __helpers__/
│ └── setupTestDb.js # Test utilities (complete)
└── visible/
├── 01-health.spec.js # Health check tests
├── 02-connect.spec.js # Database tests
├── 03-register.spec.js # Registration tests
├── 04-login.spec.js # Login & JWT tests
├── 05-protected.spec.js # Auth middleware tests
└── 06-roles.spec.js # Authorization tests
File: src/server.js
Read environment variables:
PORTfromprocess.env.PORT(default: 3000)MONGO_URIfromprocess.env.MONGO_URI(default: mongodb://localhost:27017/auth_api)
File: src/app.js
Create Express app:
- Add
express.json()middleware - Add
GET /healthroute returning{ ok: true } - Mount routes and error handlers
Test: npm test -- 01-health
File: src/db/connect.js
Implement connectDB(uri):
- Validate URI is provided
- Connect using
mongoose.connect(uri) - Return connection
Test: npm test -- 02-connect
File: src/models/user.model.js
Define User schema:
- name (String, required, trim, 2-50 chars)
- email (String, required, unique, lowercase, validated)
- password (String, required, min 6 chars,
select: false) - role (String, enum: ['user', 'admin'], default: 'user')
- timestamps enabled
Add pre-save hook:
- Hash password with
bcrypt.hash(password, 10)before saving - Only hash if password is modified
File: src/controllers/auth.controller.js
Implement register:
- Check if email already exists (409 if yes)
- Create new user (password auto-hashed by pre-save hook)
- Return 201 with user (password excluded)
File: src/routes/auth.routes.js
Add POST /register route
Test: npm test -- 03-register
File: src/utils/jwt.js
Implement JWT utility functions:
signToken(payload):
- Use
jwt.sign()from jsonwebtoken library - Sign with
process.env.JWT_SECRET - Set
expiresInfromprocess.env.JWT_EXPIRES_IN(default: '24h') - Return the signed token string
verifyToken(token):
- Use
jwt.verify()from jsonwebtoken library - Verify with
process.env.JWT_SECRET - Return decoded payload
- Let errors (invalid/expired tokens) propagate to caller
File: src/controllers/auth.controller.js
Implement login:
- Find user by email (use
.select('+password')) - Check password with
bcrypt.compare(password, user.password) - Generate JWT with
signToken({ userId, email, role }) - Return 200 with token and user (password excluded)
File: src/routes/auth.routes.js
Add POST /login route
Security: Use same error message for wrong email/password: "Invalid credentials"
Test: npm test -- 04-login
File: src/middlewares/auth.middleware.js
Implement authenticate:
- Extract Authorization header
- Verify "Bearer token" format
- Verify token with
verifyToken(token) - Find user by decoded userId
- Attach user to
req.user - Return 401 for any auth failure
File: src/controllers/auth.controller.js
Implement me:
- Return current user from
req.user
File: src/routes/auth.routes.js
Add GET /me route (protected with authenticate middleware)
Test: npm test -- 05-protected
File: src/middlewares/role.middleware.js
Implement requireRole(...roles):
- Return middleware function
- Check if user is authenticated (401 if not)
- Check if user role is allowed (403 if not)
- Call next()
File: src/controllers/user.controller.js
Implement admin-only endpoints:
listUsers- Find all usersgetUser- Find user by ID (404 if not found)deleteUser- Delete user by ID (404 if not found)
File: src/routes/user.routes.js
Add routes (all require authenticate + requireRole('admin')):
- GET
/- listUsers - GET
/:id- getUser - DELETE
/:id- deleteUser
File: src/app.js
Mount user routes at /api/users
Test: npm test -- 06-roles
Register a new user.
Request:
{
"name": "John Doe",
"email": "john@example.com",
"password": "password123"
}Response (201):
{
"user": {
"_id": "...",
"name": "John Doe",
"email": "john@example.com",
"role": "user",
"createdAt": "...",
"updatedAt": "..."
}
}Login and receive JWT token.
Request:
{
"email": "john@example.com",
"password": "password123"
}Response (200):
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"_id": "...",
"name": "John Doe",
"email": "john@example.com",
"role": "user"
}
}Get current user (requires authentication).
Headers:
Authorization: Bearer <token>
Response (200):
{
"user": {
"_id": "...",
"name": "John Doe",
"email": "john@example.com",
"role": "user"
}
}All routes require admin role.
List all users.
Headers:
Authorization: Bearer <admin-token>
Response (200):
{
"users": [
{
"_id": "...",
"name": "John Doe",
"email": "john@example.com",
"role": "user"
}
]
}Get user by ID.
Delete user by ID.
- Never store plain text passwords - Always hash with bcrypt
- Use bcrypt.compare() for verification, not string comparison
- Set password field to
select: falsein schema - Use
.select('+password')only when needed (like login)
- Store JWT_SECRET in environment variables
- Use strong secret (at least 32 characters)
- Set reasonable expiration (24h recommended)
- Include minimal data in payload (userId, email, role)
- Verify token on every protected request
- Don't leak information ("Invalid credentials" not "Email not found")
- Same message for wrong email or wrong password
- Consistent error format:
{ error: { message: "..." } }
- 401 Unauthorized - Not authenticated (no token, invalid token)
- 403 Forbidden - Authenticated but no permission (wrong role)
- 409 Conflict - Resource already exists (duplicate email)
- Authorization header:
Bearer <token> - Always check for "Bearer " prefix
- Extract token correctly:
header.split(' ')[1]
Wrong:
const user = new User({ name, email, password }); // Password saved as plain text!
await user.save();Correct:
// Pre-save hook automatically hashes password
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});Wrong:
if (password === user.password) // String comparison won't work!Correct:
const isValid = await bcrypt.compare(password, user.password);Wrong:
const user = await User.findOne({ email }); // Password not included!Correct:
const user = await User.findOne({ email }).select('+password');Wrong:
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Correct:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Wrong:
if (!user) return res.status(404).json({ error: { message: "Email not found" } });
if (!isValid) return res.status(401).json({ error: { message: "Wrong password" } });Correct:
// Same message for both cases
if (!user || !isValid) {
return res.status(401).json({ error: { message: "Invalid credentials" } });
}All tests use an in-memory MongoDB database for isolation. Tests are transparent - you can see exactly what's expected.
Run tests progressively:
npm test -- 01-health # 5 points
npm test -- 02-connect # 10 points
npm test -- 03-register # 20 points
npm test -- 04-login # 20 points
npm test -- 05-protected # 25 points
npm test -- 06-roles # 20 pointsTotal: 100 points
Copy .env.example to .env and configure:
# Database
MONGO_URI=mongodb://localhost:27017/auth_api
# Server
PORT=3000
NODE_ENV=development
# JWT Configuration
JWT_SECRET=your-super-secret-key-minimum-32-characters
JWT_EXPIRES_IN=24hGenerate a strong JWT secret:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"# Start MongoDB
docker-compose up -d
# Stop MongoDB
docker-compose down
# View logs
docker-compose logs -f mongodb
# Remove all data
docker-compose down -v- Start MongoDB:
docker-compose up -d - Check MongoDB is running:
docker-compose ps
- Copy
.env.exampleto.env - Set a strong JWT_SECRET value
- Check pre-save hook in user.model.js
- Ensure
bcrypt.hash()is called with salt rounds (10) - Verify
this.isModified('password')check
- Check Authorization header format:
Bearer <token> - Verify JWT_SECRET matches between sign and verify
- Check token hasn't expired
- 401 = Not authenticated (no/invalid token)
- 403 = Authenticated but forbidden (wrong role)
- Express Documentation
- Mongoose Documentation
- bcrypt Documentation
- JWT Introduction
- OWASP Authentication Cheat Sheet
MIT