Practice: Secure Auth API with RBAC
This exercise combines everything in Module 4: JWT access/refresh tokens, bcrypt password hashing, CORS, Helmet, rate limiting, and role-based access control. You will build a complete authentication system with three user roles (user, editor, admin) and a protected resource API that enforces permissions at the route level.
What You'll Build
An Express API with POST /auth/register, POST /auth/login, POST /auth/refresh, POST /auth/logout endpoints plus protected resource routes. Passwords are hashed with bcrypt. Login issues JWT access (15 min) and refresh (7 day) tokens. Three roles enforce permissions on /api/products. Helmet and rate limiting are applied globally.
Prerequisites
- Completed lessons 19–23 (JWT, bcrypt, OAuth, CORS/Helmet/rate-limit, RBAC)
- Node.js 18+ and npm installed
- A running MongoDB instance (local or Atlas free tier) or swap with an in-memory array
Step 1: Project Setup
mkdir secure-auth-api && cd secure-auth-api
npm init -y
npm install express bcrypt jsonwebtoken cors helmet express-rate-limit cookie-parser dotenv mongoose// .env
PORT=3000
MONGODB_URI=mongodb://localhost:27017/secure_auth
JWT_ACCESS_SECRET=your_access_secret_min_32_chars
JWT_REFRESH_SECRET=your_refresh_secret_min_32_chars
BCRYPT_ROUNDS=12Step 2: User Model and Role Config
// models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
email: { type: String, unique: true, lowercase: true, trim: true },
passwordHash: { type: String },
role: { type: String, enum: ['user','editor','admin'], default: 'user' },
refreshTokenHash: { type: String, default: null }
}, { timestamps: true });
module.exports = mongoose.model('User', userSchema);
// config/roles.js
const PERMISSIONS = {
user: ['read:products'],
editor: ['read:products','write:products'],
admin: ['read:products','write:products','delete:products','read:users']
};
function hasPermission(role, permission) {
return PERMISSIONS[role]?.includes(permission) ?? false;
}
module.exports = { PERMISSIONS, hasPermission };Step 3: JWT Utilities and Middleware
// utils/jwt.js
const jwt = require('jsonwebtoken');
const signAccess = p => jwt.sign(p, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' });
const signRefresh = p => jwt.sign(p, process.env.JWT_REFRESH_SECRET, { expiresIn: '7d' });
const verifyAccess = t => jwt.verify(t, process.env.JWT_ACCESS_SECRET);
const verifyRefresh = t => jwt.verify(t, process.env.JWT_REFRESH_SECRET);
module.exports = { signAccess, signRefresh, verifyAccess, verifyRefresh };
// middleware/authenticate.js
const { verifyAccess } = require('../utils/jwt');
module.exports = (req, res, next) => {
const auth = req.headers['authorization'];
if (!auth?.startsWith('Bearer ')) return res.status(401).json({ error: 'Missing token' });
try {
req.user = verifyAccess(auth.slice(7));
next();
} catch (e) {
res.status(401).json({ error: e.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token' });
}
};
// middleware/authorize.js
const { hasPermission } = require('../config/roles');
module.exports = permission => (req, res, next) => {
if (!hasPermission(req.user?.role, permission))
return res.status(403).json({ error: 'Forbidden' });
next();
};Step 4: Auth Routes
// routes/auth.js
const router = require('express').Router();
const bcrypt = require('bcrypt');
const User = require('../models/User');
const { signAccess, signRefresh, verifyRefresh } = require('../utils/jwt');
const ROUNDS = parseInt(process.env.BCRYPT_ROUNDS || '12', 10);
const COOKIE_OPTS = { httpOnly:true, secure:process.env.NODE_ENV==='production', sameSite:'strict', maxAge:7*24*60*60*1000 };
// Register
router.post('/register', async (req, res) => {
const { email, password, role } = req.body;
if (!email || !password || password.length < 12)
return res.status(422).json({ error: 'Email and password (min 12 chars) required' });
const exists = await User.findOne({ email });
if (exists) return res.status(409).json({ error: 'Email already registered' });
const passwordHash = await bcrypt.hash(password, ROUNDS);
// Only allow admin to assign non-user roles in production
const safeRole = ['user','editor','admin'].includes(role) ? role : 'user';
const user = await User.create({ email, passwordHash, role: safeRole });
res.status(201).json({ id: user.id, email: user.email, role: user.role });
});
// Login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash)))
return res.status(401).json({ error: 'Invalid credentials' });
const payload = { sub: user.id, role: user.role };
const accessToken = signAccess(payload);
const refreshToken = signRefresh({ sub: user.id });
await User.updateOne({ _id: user.id }, { refreshTokenHash: await bcrypt.hash(refreshToken, 10) });
res.cookie('refreshToken', refreshToken, COOKIE_OPTS);
res.json({ accessToken, expiresIn: 900 });
});
// Refresh
router.post('/refresh', async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ error: 'No refresh token' });
let decoded;
try { decoded = verifyRefresh(token); }
catch { return res.status(401).json({ error: 'Invalid refresh token' }); }
const user = await User.findById(decoded.sub);
if (!user?.refreshTokenHash || !(await bcrypt.compare(token, user.refreshTokenHash)))
return res.status(401).json({ error: 'Token reuse detected' });
const newRefresh = signRefresh({ sub: user.id });
await User.updateOne({ _id: user.id }, { refreshTokenHash: await bcrypt.hash(newRefresh, 10) });
res.cookie('refreshToken', newRefresh, COOKIE_OPTS);
res.json({ accessToken: signAccess({ sub: user.id, role: user.role }), expiresIn: 900 });
});
// Logout
router.post('/logout', async (req, res) => {
const token = req.cookies.refreshToken;
if (token) {
try {
const { sub } = verifyRefresh(token);
await User.updateOne({ _id: sub }, { refreshTokenHash: null });
} catch {}
}
res.clearCookie('refreshToken');
res.status(204).end();
});
module.exports = router;Step 5: Protected Resource Routes and App Wiring
// routes/products.js
const router = require('express').Router();
const authenticate = require('../middleware/authenticate');
const authorize = require('../middleware/authorize');
const products = [
{ id: 1, name: 'Cricket Bat', price: 120 },
{ id: 2, name: 'Helmet', price: 85 }
];
router.get('/', authenticate, authorize('read:products'), (req, res) => res.json(products));
router.post('/', authenticate, authorize('write:products'), (req, res) => {
const p = { id: products.length + 1, ...req.body };
products.push(p);
res.status(201).json(p);
});
router.delete('/:id', authenticate, authorize('delete:products'), (req, res) => {
const idx = products.findIndex(p => p.id === +req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Not found' });
products.splice(idx, 1);
res.status(204).end();
});
module.exports = router;
// app.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const mongoose = require('mongoose');
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({ origin: 'http://localhost:3000', credentials: true }));
app.use(rateLimit({ windowMs: 15*60*1000, max: 100 }));
app.use(express.json());
app.use(cookieParser());
// Rate-limit auth endpoints strictly
const authLimiter = rateLimit({ windowMs: 15*60*1000, max: 10 });
app.use('/auth', authLimiter, require('./routes/auth'));
app.use('/api/products', require('./routes/products'));
// Global error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({ error: err.message || 'Internal server error' });
});
mongoose.connect(process.env.MONGODB_URI).then(() => {
app.listen(process.env.PORT, () =>
console.log('Secure Auth API on port', process.env.PORT));
});
module.exports = app;Step 6: Manual Testing with curl
# Register a user and an admin
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"SecureP@ss123","role":"user"}'
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"AdminP@ss456","role":"admin"}'
# Login as user, save access token
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-c cookies.txt \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"SecureP@ss123"}' | jq -r .accessToken)
# Read products (allowed for user role)
curl http://localhost:3000/api/products -H "Authorization: Bearer $TOKEN"
# Try to DELETE (forbidden for user role)
curl -X DELETE http://localhost:3000/api/products/1 -H "Authorization: Bearer $TOKEN"
# Expected: 403 Forbidden
# Refresh token
curl -X POST http://localhost:3000/auth/refresh -b cookies.txt -c cookies.txt
# Logout
curl -X POST http://localhost:3000/auth/logout -b cookies.txtIn this exercise, any role (including 'admin') can be set at registration for testing convenience. In production, only an existing admin should be able to elevate a user's role. Always enforce role assignment server-side.
The refresh token is stored as a bcrypt hash in MongoDB. This means even a database breach does not give an attacker usable refresh tokens — they would need to brute-force the hash at bcrypt cost factor 10.
- Hash passwords at registration with bcrypt; compare with bcrypt.compare at login
- Issue short-lived access tokens (15 min) in response body, long-lived refresh tokens as HttpOnly cookies
- Store a bcrypt hash of the refresh token in the database for rotation and revocation
- The authorize(permission) middleware runs after authenticate and checks req.user.role
- Apply Helmet and rate limiting before any route handlers in app.js
- Never trust role values from request bodies; validate against an allowed enum