AuthorizationAuthorization & Role-Based Access Control (RBAC)
Authentication answers "who are you?" Authorization answers "what are you allowed to do?" Once a user is logged in, the server needs to enforce boundaries. A regular user shouldn't be able to delete another user's account. An editor shouldn't be able to change billing settings. A guest shouldn't be able to see the admin dashboard. These boundaries are the job of Authorization.
Getting authorization wrong is one of the most dangerous backend failures. A 2021 OWASP report ranked "Broken Access Control" as the #1 web application security vulnerability.
Authentication vs Authorization — The Critical Distinction
🔐Authentication (AuthN)"Who are you?" — Verify identity. Login with email/password. Returns: "This is user ID 5."
🛡️Authorization (AuthZ)"What can you do?" — Check permissions. "Can user ID 5 delete this post?" Returns: yes/no.
A user can be authenticated (logged in) but not authorized (allowed to do what they're trying to do). Always check both.
Role-Based Access Control (RBAC)
The most common authorization pattern is RBAC. Users are assigned one or more roles (like "admin", "editor", "user", "guest"), and each role has a defined set of permissions. Checking authorization means checking whether the user's role has the required permission for the requested action.
const PERMISSIONS = {
guest: ['read:posts', 'read:users'],
user: ['read:posts', 'read:users', 'create:posts', 'update:own-posts', 'delete:own-posts'],
editor: ['read:posts', 'read:users', 'create:posts', 'update:any-post', 'delete:any-post'],
admin: ['*']
};
const requirePermission = (permission) => {
return (req, res, next) => {
const userRole = req.user?.role || 'guest';
const allowed = PERMISSIONS[userRole] || [];
if (allowed.includes('*') || allowed.includes(permission)) {
return next();
}
res.status(403).json({
error: 'Forbidden',
message: `Your role (${userRole}) does not have permission: ${permission}`
});
};
};
app.get('/admin/users', authenticate, requirePermission('read:users'), getAllUsers);
app.delete('/posts/:id', authenticate, requirePermission('delete:any-post'), deletePost);
app.get('/billing/invoices', authenticate, requirePermission('read:billing'), getInvoices);
Resource-Level Authorization (Ownership Checks)
Role-based checks are not enough on their own. Even with the right role, a user should only be able to modify resources they own. This is called resource-level authorization or ownership checking, and it is one of the most commonly missed security controls.
app.delete('/posts/:id', authenticate, async (req, res) => {
const postId = parseInt(req.params.id);
const post = await prisma.post.findUnique({ where: { id: postId } });
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
const isOwner = post.userId === req.user.userId;
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
return res.status(403).json({
error: 'You do not have permission to delete this post'
});
}
await prisma.post.delete({ where: { id: postId } });
res.status(204).send();
});
⚠️ Insecure Direct Object Reference (IDOR):IDOR is when you expose a resource's ID in the URL (like /invoices/1042) without verifying that the current user actually owns invoice 1042. An attacker can simply change the ID to 1043, 1044, 1045 and read every other user's invoice. ALWAYS add an ownership check: WHERE id = $1 AND user_id = $2.
Storing Roles in the Database
Roles should be stored in your database so they can be dynamically assigned and changed. Never hardcode a user's role into the JWT payload as the permanent truth — always verify against the database for sensitive operations.
CREATE TYPE user_role AS ENUM ('guest', 'user', 'editor', 'admin');
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
role user_role NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE role_permissions (
role_id INTEGER REFERENCES roles(id),
permission_id INTEGER REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id INTEGER REFERENCES users(id),
role_id INTEGER REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);
The Complete Auth + AuthZ Middleware Stack
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
const authenticate = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
const freshUser = await prisma.user.findUnique({
where: { id: req.user.userId },
select: { id: true, role: true, isBanned: true }
});
if (!freshUser || freshUser.isBanned) {
return res.status(401).json({ error: 'Account suspended' });
}
req.user = { ...req.user, role: freshUser.role };
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
const authorize = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: `Access denied. Required roles: ${roles.join(' or ')}`
});
}
next();
};
app.get('/admin/users', authenticate, authorize('admin'), getUsers);
app.put('/posts/:id', authenticate, authorize('admin', 'editor'), updatePost);
app.get('/my-profile', authenticate, getProfile);