Skip to content

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

migrations/001_rbac_schema.sql
-- Users table
CREATE 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 table
CREATE 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 table
CREATE 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

migrations/002_rbac_relationships.sql
-- 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

migrations/003_rbac_seed.sql
-- Insert default roles
INSERT INTO roles (name, description) VALUES
('admin', 'Full system access'),
('editor', 'Can create and edit content'),
('viewer', 'Read-only access');
-- Insert permissions
INSERT 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 role
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'admin';
-- Assign permissions to editor role
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.name IN ('content:read', 'content:write')
WHERE r.name = 'editor';
-- Assign permissions to viewer role
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN 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

migrations/004_rbac_indexes.sql
-- Speed up permission lookups
CREATE 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 pattern
CREATE 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

db/permissions.js
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

middleware/auth.js
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

routes/content.js
const express = require('express');
const router = express.Router();
const { requirePermission } = require('../middleware/auth');
// Anyone authenticated can read content
router.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 create
router.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 delete
router.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

routes/auth.js
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

contexts/PermissionContext.jsx
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

hooks/useHasPermission.js
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

components/PermissionGuard.jsx
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

components/ContentList.jsx
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

pages/AdminPanel.jsx
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

middleware/cachedPermissions.js
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

routes/admin.js
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:

  1. Database Schema: Use normalized tables (users, roles, permissions) with many-to-many junction tables for flexibility.

  2. Express Middleware: Create reusable middleware factories like requirePermission() that check permissions before route handlers run.

  3. React Components: Build declarative permission guards (<Can>, <HasRole>) and hooks (useHasPermission) that hide unauthorized UI elements.

  4. 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