How to Implement Role-Based Access Control (RBAC) in a PERN Stack Application
Problem
Basic authentication is not enough. I built a PERN stack application (PostgreSQL, Express.js, React, Node.js) with simple login functionality. Users could sign up, log in, and access protected routes. But then requirements changed.
My client needed different user types: admins who could manage everything, editors who could create and edit content, and viewers who could only read. Suddenly, my simple isAuthenticated check wasn’t sufficient. I needed granular control over who could do what.
That’s when I implemented Role-Based Access Control (RBAC). It wasn’t as straightforward as I expected. The challenge: RBAC spans three layers of the stack. The database needs the right schema. The API needs middleware to check permissions. The frontend needs to conditionally render UI based on roles.
Here’s how I implemented a complete RBAC system across the entire PERN stack.
Database Schema Design
I started with PostgreSQL. RBAC needs a normalized schema with three core tables: users, roles, and permissions. The key insight is that permissions are granular actions, roles group permissions, and users are assigned roles.
Core Tables
-- Users tableCREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- Roles tableCREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(50) NOT NULL UNIQUE, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- Permissions tableCREATE TABLE permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(100) NOT NULL UNIQUE, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());This gives me three independent entities. Now I need junction tables to connect them.
Junction Tables
-- User-Role junction (many-to-many)CREATE TABLE user_roles ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), assigned_by UUID REFERENCES users(id), PRIMARY KEY (user_id, role_id));
-- Role-Permission junction (many-to-many)CREATE TABLE role_permissions ( role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id));Why many-to-many relationships? A user can have multiple roles (e.g., both “editor” and “reviewer”). A role has multiple permissions. This flexibility is essential for real-world applications.
Seed Data
-- Insert default rolesINSERT INTO roles (name, description) VALUES ('admin', 'Full system access'), ('editor', 'Can create and edit content'), ('viewer', 'Read-only access');
-- Insert permissionsINSERT INTO permissions (name, description) VALUES ('users:read', 'View user profiles'), ('users:write', 'Create and update users'), ('users:delete', 'Delete users'), ('content:read', 'View content'), ('content:write', 'Create and edit content'), ('content:delete', 'Delete content');
-- Assign permissions to admin roleINSERT INTO role_permissions (role_id, permission_id)SELECT r.id, p.idFROM roles rCROSS JOIN permissions pWHERE r.name = 'admin';
-- Assign permissions to editor roleINSERT INTO role_permissions (role_id, permission_id)SELECT r.id, p.idFROM roles rJOIN permissions p ON p.name IN ('content:read', 'content:write')WHERE r.name = 'editor';
-- Assign permissions to viewer roleINSERT INTO role_permissions (role_id, permission_id)SELECT r.id, p.idFROM roles rJOIN permissions p ON p.name = 'content:read'WHERE r.name = 'viewer';The naming convention resource:action keeps permissions organized. When I need to check if someone can delete content, I look for the content:delete permission.
Performance Indexes
-- Speed up permission lookupsCREATE INDEX idx_user_roles_user_id ON user_roles(user_id);CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id);
-- Composite index for the most common query patternCREATE INDEX idx_user_permission_check ON user_roles(user_id)INCLUDE (role_id);These indexes make permission checking fast. The most common query is “does user X have permission Y?” With proper indexing, this becomes a simple join that PostgreSQL executes in milliseconds.
Express.js Authorization Middleware
With the schema in place, I needed Express.js middleware to check permissions on API requests. The middleware runs after authentication and before the route handler.
Permission Query Function
async function getUserPermissions(userId, client) { const result = await client.query(` SELECT DISTINCT p.name FROM permissions p JOIN role_permissions rp ON p.id = rp.permission_id JOIN user_roles ur ON rp.role_id = ur.role_id WHERE ur.user_id = $1 `, [userId]);
return result.rows.map(row => row.name);}
async function userHasPermission(userId, permissionName, client) { const result = await client.query(` SELECT 1 FROM permissions p JOIN role_permissions rp ON p.id = rp.permission_id JOIN user_roles ur ON rp.role_id = ur.role_id WHERE ur.user_id = $1 AND p.name = $2 LIMIT 1 `, [userId, permissionName]);
return result.rows.length > 0;}
module.exports = { getUserPermissions, userHasPermission };The userHasPermission function is optimized for the common case: checking a single permission. It returns as soon as it finds a match.
Authorization Middleware Factory
const { userHasPermission } = require('../db/permissions');
function requirePermission(permission) { return async (req, res, next) => { // Assume authentication middleware already set req.user if (!req.user) { return res.status(401).json({ error: 'Unauthorized' }); }
try { const hasPermission = await userHasPermission( req.user.id, permission, req.dbClient );
if (!hasPermission) { return res.status(403).json({ error: 'Forbidden', required: permission }); }
next(); } catch (err) { console.error('Permission check failed:', err); res.status(500).json({ error: 'Internal server error' }); } };}
function requireRole(roleName) { return async (req, res, next) => { if (!req.user) { return res.status(401).json({ error: 'Unauthorized' }); }
try { const result = await req.dbClient.query(` SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = $1 AND r.name = $2 LIMIT 1 `, [req.user.id, roleName]);
if (result.rows.length === 0) { return res.status(403).json({ error: 'Forbidden', requiredRole: roleName }); }
next(); } catch (err) { console.error('Role check failed:', err); res.status(500).json({ error: 'Internal server error' }); } };}
module.exports = { requirePermission, requireRole };I created two middleware functions. requirePermission checks for specific permissions. requireRole checks for role membership. Use requirePermission for fine-grained control, requireRole when all permissions in a role are needed.
Using Middleware in Routes
const express = require('express');const router = express.Router();const { requirePermission } = require('../middleware/auth');
// Anyone authenticated can read contentrouter.get('/', async (req, res) => { const content = await req.dbClient.query( 'SELECT * FROM content ORDER BY created_at DESC' ); res.json(content.rows);});
// Only users with content:write can createrouter.post('/', requirePermission('content:write'), async (req, res) => { const { title, body } = req.body; const result = await req.dbClient.query( 'INSERT INTO content (title, body, author_id) VALUES ($1, $2, $3) RETURNING *', [title, body, req.user.id] ); res.status(201).json(result.rows[0]);});
// Only users with content:delete can deleterouter.delete('/:id', requirePermission('content:delete'), async (req, res) => { await req.dbClient.query( 'DELETE FROM content WHERE id = $1', [req.params.id] ); res.status(204).send();});
module.exports = router;Each route specifies what permission it requires. The middleware handles the check before the route handler runs.
Loading User Permissions at Login
const bcrypt = require('bcrypt');const { getUserPermissions } = require('../db/permissions');
router.post('/login', async (req, res) => { const { email, password } = req.body;
// Find user const userResult = await req.dbClient.query( 'SELECT * FROM users WHERE email = $1', [email] );
if (userResult.rows.length === 0) { return res.status(401).json({ error: 'Invalid credentials' }); }
const user = userResult.rows[0];
// Verify password const validPassword = await bcrypt.compare(password, user.password_hash); if (!validPassword) { return res.status(401).json({ error: 'Invalid credentials' }); }
// Load permissions const permissions = await getUserPermissions(user.id, req.dbClient);
// Load roles const rolesResult = await req.dbClient.query(` SELECT r.name FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = $1 `, [user.id]);
const roles = rolesResult.rows.map(r => r.name);
// Return user with permissions and roles res.json({ user: { id: user.id, email: user.email }, permissions, roles });});I load permissions and roles at login time. The frontend stores these in state and uses them to conditionally render UI.
React Permission Management
The frontend needs to know what the user can do. I store permissions in React context and create reusable components for permission checks.
Permission Context
import { createContext, useContext, useMemo } from 'react';
const PermissionContext = createContext(null);
export function PermissionProvider({ children, permissions, roles }) { const value = useMemo(() => ({ permissions: new Set(permissions || []), roles: new Set(roles || []) }), [permissions, roles]);
return ( <PermissionContext.Provider value={value}> {children} </PermissionContext.Provider> );}
export function usePermissions() { const context = useContext(PermissionContext); if (!context) { throw new Error('usePermissions must be used within PermissionProvider'); } return context;}Using Set for permissions and roles makes checking membership an O(1) operation.
Permission Check Hook
import { usePermissions } from '../contexts/PermissionContext';
export function useHasPermission(permission) { const { permissions } = usePermissions(); return permissions.has(permission);}
export function useHasRole(role) { const { roles } = usePermissions(); return roles.has(role);}
export function useHasAnyPermission(permissionList) { const { permissions } = usePermissions(); return permissionList.some(p => permissions.has(p));}
export function useHasAllPermissions(permissionList) { const { permissions } = usePermissions(); return permissionList.every(p => permissions.has(p));}These hooks provide different ways to check permissions. useHasAnyPermission is useful when multiple permissions grant access. useHasAllPermissions is useful for sensitive operations.
Permission Guard Component
import { useHasPermission, useHasRole } from '../hooks/useHasPermission';
export function Can({ permission, children, fallback = null }) { const hasPermission = useHasPermission(permission);
if (!hasPermission) { return fallback; }
return children;}
export function HasRole({ role, children, fallback = null }) { const hasRole = useHasRole(role);
if (!hasRole) { return fallback; }
return children;}
export function CanAny({ permissions, children, fallback = null }) { const { permissions: userPermissions } = usePermissions(); const hasAny = permissions.some(p => userPermissions.has(p));
if (!hasAny) { return fallback; }
return children;}These components make declarative permission checks easy. Wrap any UI element in <Can permission="content:delete"> to hide it from users without permission.
Usage in Components
import { Can } from './PermissionGuard';import { useHasPermission } from '../hooks/useHasPermission';
function ContentList({ content, onDelete }) { return ( <div className="content-list"> {content.map(item => ( <ContentItem key={item.id} item={item} onDelete={onDelete} /> ))} </div> );}
function ContentItem({ item, onDelete }) { const canEdit = useHasPermission('content:write'); const canDelete = useHasPermission('content:delete');
return ( <div className="content-item"> <h3>{item.title}</h3> <p>{item.body}</p>
<div className="actions"> {canEdit && ( <button onClick={() => navigate(`/edit/${item.id}`)}> Edit </button> )}
<Can permission="content:delete"> <button className="danger" onClick={() => onDelete(item.id)} > Delete </button> </Can> </div> </div> );}I use two approaches here. useHasPermission hook for conditional logic, <Can> component for conditional rendering. Both are valid; choose based on what’s cleaner in context.
Admin Panel Example
import { HasRole } from '../components/PermissionGuard';
function AdminPanel() { return ( <div> <h1>Admin Panel</h1>
<HasRole role="admin"> <UserManagement /> <RoleAssignment /> <SystemSettings /> </HasRole>
<HasRole role="editor" fallback={ <p>You need admin role to access this panel.</p> }> <ContentModeration /> </HasRole> </div> );}The <HasRole> component shows content only to users with specific roles. The fallback prop shows alternative content to unauthorized users.
Caching Permissions for Performance
On every API request, querying the database for permissions adds latency. For high-traffic applications, I cache permissions in Redis.
Server-Side Caching
const { getUserPermissions } = require('../db/permissions');
async function getCachedPermissions(userId, dbClient, redisClient) { const cacheKey = `user:${userId}:permissions`;
// Try cache first const cached = await redisClient.get(cacheKey); if (cached) { return JSON.parse(cached); }
// Cache miss - query database const permissions = await getUserPermissions(userId, dbClient);
// Cache for 5 minutes await redisClient.setEx(cacheKey, 300, JSON.stringify(permissions));
return permissions;}
async function invalidatePermissionCache(userId, redisClient) { await redisClient.del(`user:${userId}:permissions`);}
module.exports = { getCachedPermissions, invalidatePermissionCache };The cache has a short TTL (5 minutes). When user roles change, I invalidate the cache immediately.
Cache Invalidation on Role Change
const { invalidatePermissionCache } = require('../middleware/cachedPermissions');
router.post( '/users/:userId/roles', requirePermission('users:write'), async (req, res) => { const { roleId } = req.body; const { userId } = req.params;
// Assign role await req.dbClient.query( 'INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES ($1, $2, $3)', [userId, roleId, req.user.id] );
// Invalidate cache await invalidatePermissionCache(userId, req.redisClient);
res.status(204).send(); });Whenever roles or permissions change, the cache must be invalidated. Otherwise, users might see stale permissions.
Summary
In this post, I showed how to implement Role-Based Access Control (RBAC) across a PERN stack application. The key points are:
-
Database Schema: Use normalized tables (users, roles, permissions) with many-to-many junction tables for flexibility.
-
Express Middleware: Create reusable middleware factories like
requirePermission()that check permissions before route handlers run. -
React Components: Build declarative permission guards (
<Can>,<HasRole>) and hooks (useHasPermission) that hide unauthorized UI elements. -
Caching: Cache permissions server-side to reduce database load, with proper cache invalidation when roles change.
RBAC implementation spans all three layers. Each layer has a specific responsibility: PostgreSQL stores and queries permissions efficiently, Express.js enforces permissions at the API boundary, and React conditionally renders UI based on what the user can do.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments