100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Node.js & Express Backend
60 minintermediate

Practice — Secure Auth API with RBAC

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.

Analogy🏏Cricket
🏏 Think of it like cricket: A full stadium security operation is not one guard but a coordinated system — verified accreditation at issue, tamper-proof passes, capped entry attempts, hardened gates, and tiered access for players, staff and officials, all working together. Just as no single measure secures a venue alone, this exercise combines everything from Module 4 into one system: JWT access and refresh tokens, bcrypt password hashing, CORS, Helmet, rate limiting, and role-based access control. Just as a stadium issues players, staff and press distinctly tiered accreditation, you build three roles — user, editor, admin — each with its own permission set. Just as sensitive areas admit only the right tier, a protected resource API enforces permissions at the route level. Just as security is judged by how the layers hold together under a real crowd, this is judged by how the auth pieces integrate. The payoff: assembling the full stack proves you can secure an API end-to-end, not just in isolated drills.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: You are the data operations manager at the BCCI analytics division, responsible for processing the complete ball-by-ball feed from an IPL season — 74 matches, 888 overs, over 5,000 deliveries. The raw feed arrives as a continuous data stream from the scoring tablets at each ground. Your job is to build the pipeline that ingests that stream, filters out extras and wide deliveries for certain statistics, aggregates the useful data into player-level summaries, and publishes the final report to the BCCI's official statistics portal before the next morning's press conference. Just as the BCCI would never ask analysts to hold the entire season's data in memory before starting analysis (they process each match's data as it arrives from the ground), your pipeline processes each CSV chunk as it streams from disk — maintaining a constant memory footprint regardless of how many seasons of data you process.

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

bash
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
javascript
// .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=12

Step 2: User Model and Role Config

javascript
// 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

javascript
// 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

javascript
// 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

javascript
// 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

bash
# 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.txt

In 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
Lesson 24 of 36
0% complete