Role-Based Access Control
Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L
You vibe-coded a beautiful admin dashboard. You hid the "Delete User" button for regular users. You shipped it. Three hours later someone POSTed directly to
/api/admin/delete-userand wiped half your database. The UI was never the door — the API was. RBAC done right means the server enforces every rule, every time, on every request.
⚠️ The vibe trap
The classic mistake is hiding things in the UI and calling it security. You wrap a button in {user.role === 'admin' && <DeleteButton />} and feel safe — but the API endpoint behind that button has no idea what role anyone has. Any user who opens DevTools, copies the fetch call, and runs it from the console is now your admin. The second trap is even sneakier: you add a role check to the endpoint, but forget that a "user"-role account can still edit someone else's row just by changing the ID in the request body. That's a broken object-level authorization bug — one of OWASP's most exploited vulnerabilities — and it lives entirely below the UI layer.
🗄️ Modeling Roles and Permissions in the Database
Roles are categories of users. Permissions are specific actions. The mapping in between is where the real policy lives.
-- Users belong to one or more roles
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL -- 'admin', 'moderator', 'user'
);
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- Roles grant named permissions
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL -- 'posts:delete', 'users:ban', 'billing:read'
);
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
Mental model: Think of roles as job titles and permissions as keys on a keyring. A "moderator" title comes with the posts:delete and comments:hide keys. A "user" title comes with posts:create and comments:create. You assign titles to people; titles carry the keys.
Why this matters: Storing permissions in the DB means you can grant or revoke access without a redeploy. You can also audit exactly what any role can do with a simple JOIN.
Common mistake: Skipping the permissions table and hard-coding role names in every if statement. Now your policy is scattered across 40 files and changing one rule requires a grep-and-pray refactor.
🔒 Centralizing Policy: requireRole and requirePermission Middleware
Never scatter if (user.role !== 'admin') return 403 across your route handlers. Put it in one place.
// middleware/auth.js
/**
* Factory that returns middleware enforcing a required role.
* Usage: router.delete('/posts/:id', requireRole('moderator'), handler)
*/
export function requireRole(...allowedRoles) {
return (req, res, next) => {
const userRoles = req.user?.roles ?? []; // set by your JWT/session middleware
const hasRole = allowedRoles.some(r => userRoles.includes(r));
if (!hasRole) {
return res.status(403).json({ error: 'Forbidden: insufficient role' });
}
next();
};
}
/**
* Factory that returns middleware enforcing a named permission.
* Usage: router.post('/users/:id/ban', requirePermission('users:ban'), handler)
*/
export function requirePermission(permission) {
return (req, res, next) => {
const userPermissions = req.user?.permissions ?? []; // expand from roles at login time
if (!userPermissions.includes(permission)) {
return res.status(403).json({ error: 'Forbidden: missing permission' });
}
next();
};
}
Mental model: Middleware is a checkpoint on the highway. The route handler is the destination. If your car doesn't pass inspection at the checkpoint, it never reaches the destination — no matter what the driver claims.
Why this matters: Centralizing the check means you fix a bug once and it propagates everywhere. It also makes a security audit trivial: grep for requireRole / requirePermission usages and you can see every protected route in seconds.
Common mistake: Writing requireRole('admin', 'superadmin') in one place and requireRole('superAdmin') (different casing) somewhere else. Enum-ify your role names as constants.
// constants/roles.js
export const ROLES = Object.freeze({
ADMIN: 'admin',
MODERATOR: 'moderator',
USER: 'user',
});
export const PERMISSIONS = Object.freeze({
POSTS_DELETE: 'posts:delete',
USERS_BAN: 'users:ban',
BILLING_READ: 'billing:read',
});
🔍 Resource-Level Ownership: Stopping IDOR Cold
RBAC controls what kind of action a role can take. Ownership checks control whose data they can act on. You need both.
// routes/posts.js
import { requirePermission } from '../middleware/auth.js';
import { PERMISSIONS } from '../constants/roles.js';
import { db } from '../db.js';
// DELETE /posts/:id — moderators can delete any post; regular users can only delete their own
router.delete(
'/posts/:id',
// Step 1: must be authenticated (set upstream in your session/JWT middleware)
requireAuth(),
async (req, res) => {
const post = await db.query(
'SELECT * FROM posts WHERE id = $1',
[req.params.id]
);
if (!post) {
return res.status(404).json({ error: 'Not found' });
}
const userRoles = req.user.roles ?? [];
const isModerator = userRoles.includes('moderator') || userRoles.includes('admin');
const isOwner = post.author_id === req.user.id;
// Step 2: role OR ownership — but not neither
if (!isModerator && !isOwner) {
return res.status(403).json({ error: 'Forbidden' });
}
await db.query('DELETE FROM posts WHERE id = $1', [req.params.id]);
return res.status(204).send();
}
);
Mental model: RBAC is the velvet rope at the club entrance. Ownership checks are the wristband that says you paid for this table, not any table. You need both or someone crashes someone else's party.
Why this matters: Insecure Direct Object Reference (IDOR) is OWASP API Security #1. An attacker changes ?id=123 to ?id=124 and edits your data. The ownership check is the only thing that stops them — because the ID was valid, authentication passed, and the role check passed too.
Common mistake: Fetching the resource after the 404 check but before the ownership check, then returning a different error message for "not found" vs "forbidden." This leaks whether a resource exists at all. Always return 404 for both cases when the requester has no business knowing the resource exists.
🚨 Admin Escalation Risks and Default-Deny
Two more rules that save you from yourself:
Default-deny: If no explicit rule grants access, access is denied. Never write if (user.role === 'banned') return 403 — that means everyone else is allowed. Write if (user.role !== 'admin') return 403 instead, so adding a new role doesn't accidentally inherit admin access.
Admin escalation: Only admins should be able to assign roles — and they should not be able to assign a role higher than their own. Otherwise a compromised moderator account can promote itself.
// routes/admin.js — PATCH /users/:id/role
router.patch(
'/users/:id/role',
requireRole(ROLES.ADMIN),
async (req, res) => {
const { newRole } = req.body;
// Validate the incoming role is a real, known role
const knownRoles = Object.values(ROLES);
if (!knownRoles.includes(newRole)) {
return res.status(400).json({ error: 'Invalid role' });
}
// Prevent privilege escalation: admin can grant up to their own level only
const ROLE_RANK = { user: 0, moderator: 1, admin: 2 };
const requestorMaxRank = Math.max(...req.user.roles.map(r => ROLE_RANK[r] ?? -1));
if ((ROLE_RANK[newRole] ?? -1) > requestorMaxRank) {
return res.status(403).json({ error: 'Cannot grant a role higher than your own' });
}
await db.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2
ON CONFLICT DO NOTHING`,
[req.params.id, newRole]
);
return res.status(200).json({ ok: true });
}
);
Mental model: A bank manager can hand out teller badges. They cannot hand out bank-president badges. Hard-coding the rank ceiling enforces this — no matter how cleverly someone crafts the request body.
Why this matters: If any admin can grant any role, one compromised admin account means every account is at risk of escalation. Rank-ceiling enforcement is defense in depth.
Common mistake: Storing the role as a plain string in the JWT payload and never re-fetching from the DB. If you revoke someone's admin role but their token is still valid for 24 hours, they're still admin in every middleware check. Either use short-lived tokens and re-validate roles on each request, or store a roles version number in the DB and check it on each call.
🛠️ Your Mission
Pick one protected action in your app — something only certain users should be able to do (edit a post, close a ticket, publish content, delete a record). Then:
- Add a
rolestable and at least two role constants to your codebase. - Write a
requireRoleorrequirePermissionmiddleware and mount it on your chosen route. - Add an ownership check inside the handler that verifies the requesting user owns the target resource (or has an elevated role that bypasses the ownership requirement).
- Write a quick test: log in as a normal user, try to act on another user's resource, and confirm you get a 403 — not a 500, not a 200.
✅ You're done when…
- You have run every item in the Security Audit Checklist (specifically API1: Broken Object Level Authorization and API5: Broken Function Level Authorization) against your protected route and confirmed no findings
- A role check is enforced in server-side middleware — not only in the UI — for at least one route
- An ownership check runs before any mutating DB query on a user-owned resource
- Role assignment is gated to admin-only and rejects attempts to grant roles above the requestor's rank
- All role and permission names are defined as constants in one file — no magic strings scattered across handlers
- Removing a role from a user takes effect on the next request (token re-validation or short TTL confirmed)
➡️ Next: The OWASP Top 10, Part 1. Build It Right, Or Don't Build It At All. 🏛️