Build a role-based access control (RBAC) system with Replit in 2-4 hours. You'll create an Express API with PostgreSQL (Drizzle ORM) for users, roles, permissions, and audit logs, plus a permission matrix admin UI and two key middlewares: authenticate() and authorize(resource, action). Replit Agent scaffolds the full system from one prompt. Deploy on Autoscale.
What you're building
Most applications start with a binary admin/user distinction that breaks down the moment you need a 'moderator' who can edit but not delete, or a 'finance viewer' who can see invoices but not create them. Role-based access control (RBAC) solves this by defining a matrix of who can do what, with roles as the grouping mechanism between users and permissions.
Replit Agent generates the full Express + PostgreSQL backend from a single prompt, including the schema, seed data for default roles, and the two key middleware functions — `authenticate()` and `authorize()` — that any route in any Express application can use. The hardest part of RBAC — the permission check query — is optimized with a short in-memory cache so it doesn't hit the database on every single request.
The architecture separates identity (Replit Auth provides who you are) from authorization (RBAC determines what you can do). The `permissions` table uses a `resource + action` model: resources are noun-like entities (posts, users, invoices) and actions are the four CRUD verbs. Roles bundle sets of permissions. Users can hold multiple roles, and their effective permissions are the union of all role permissions.
Final result
A reusable RBAC system with users, roles, permissions, a permission matrix admin UI, role assignment management, an audit log, and the authenticate/authorize middleware pair ready to plug into any Express application.
Tech stack
Prerequisites
- A Replit account (Free tier is sufficient)
- Basic understanding of what APIs and middleware do (no coding experience needed)
- A list of resources your app has (e.g. posts, users, orders, invoices) before you start
- Optional: existing Express app to plug this RBAC system into
Build steps
Scaffold the RBAC system with Replit Agent
Open Replit and use the Agent prompt below. Agent will generate the full Express server, Drizzle schema for users, roles, permissions, role_permissions, user_roles, and the audit log, plus the seed data for default roles and a React admin frontend.
1// Paste this into Replit Agent:2// Build a role-based access control (RBAC) system with Express and PostgreSQL (Drizzle ORM).3// Schema:4// users (id serial PK, user_id text unique, email text, display_name text,5// is_active bool default true, created_at),6// roles (id serial PK, name text unique, description text,7// is_system bool default false, created_at),8// permissions (id serial PK, resource text, action text enum create/read/update/delete,9// description text, UNIQUE resource+action),10// role_permissions (id serial PK, role_id int references roles,11// permission_id int references permissions, UNIQUE role_id+permission_id),12// user_roles (id serial PK, user_id int references users,13// role_id int references roles, scope text, granted_by text,14// granted_at timestamp, UNIQUE user_id+role_id+scope),15// permission_audit_log (id serial PK, user_id int references users,16// action text, resource text, details jsonb, ip_address text, created_at).17// Routes: GET /api/users, PATCH /api/users/:id/roles,18// GET /api/roles, POST /api/roles, PUT /api/roles/:id,19// DELETE /api/roles/:id (non-system only),20// GET /api/roles/:id/permissions, PUT /api/roles/:id/permissions (bulk update),21// GET /api/permissions, GET /api/me/permissions.22// Core middlewares: authenticate() — loads user from Replit Auth user_id, builds permission set,23// authorize(resource, action) — checks if permission is in the set, returns 403 if not.24// Seed database on first run: admin role (all permissions), editor (create/read/update),25// viewer (read only). Seed all resource+action pairs for resources: posts, users, orders.26// React admin: three tabs — Users (data table with role badges, active toggle),27// Roles (permission matrix grid: resources as rows, CRUD columns, checkboxes),28// Audit Log (data table). Replit Auth for identity. Bind server to 0.0.0.0.Pro tip: In the Agent prompt, list the actual resources your app uses instead of 'posts, users, orders'. This seeds the permissions table with the right resource names from the start.
Expected result: Agent creates the full project. The preview shows the RBAC admin UI with three tabs.
Build the authenticate middleware with permission caching
The authenticate middleware loads the user from the database on every request, then builds their effective permissions as a Set. Storing this in `req.permissions` means the authorize middleware can check it without another database query. The permission set is rebuilt on each request, so changes take effect immediately without a TTL cache.
1// server/middleware/authenticate.js2const { db } = require('../db');3const { users, userRoles, rolePermissions, permissions } = require('../schema');4const { eq } = require('drizzle-orm');56async function authenticate(req, res, next) {7 // Replit Auth injects the user's identity8 const replitUserId = req.headers['x-replit-user-id'];9 const replitUserName = req.headers['x-replit-user-name'];1011 if (!replitUserId) {12 return res.status(401).json({ error: 'Authentication required' });13 }1415 // Load or auto-create user record16 let [user] = await db.select()17 .from(users)18 .where(eq(users.userId, replitUserId))19 .limit(1);2021 if (!user) {22 // First login — create user with viewer role23 [user] = await db.insert(users).values({24 userId: replitUserId,25 email: `${replitUserName}@replit`,26 displayName: replitUserName,27 }).returning();2829 // Assign viewer role by default30 const [viewerRole] = await db.select().from(require('../schema').roles)31 .where(eq(require('../schema').roles.name, 'viewer')).limit(1);3233 if (viewerRole) {34 await db.insert(userRoles).values({35 userId: user.id,36 roleId: viewerRole.id,37 grantedBy: 'system',38 }).onConflictDoNothing();39 }40 }4142 if (!user.isActive) {43 return res.status(403).json({ error: 'Account disabled' });44 }4546 // Build effective permission set: union of all role permissions47 const rows = await db48 .select({49 resource: permissions.resource,50 action: permissions.action,51 })52 .from(userRoles)53 .innerJoin(rolePermissions, eq(userRoles.roleId, rolePermissions.roleId))54 .innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))55 .where(eq(userRoles.userId, user.id));5657 // Store as a Set for O(1) lookups in authorize()58 req.user = user;59 req.permissions = new Set(rows.map(r => `${r.resource}:${r.action}`));60 next();61}6263module.exports = { authenticate };Pro tip: For high-traffic apps, add a 60-second in-memory TTL cache keyed by user.id. This avoids the permission query on every single request while still reflecting changes within a minute.
Build the authorize middleware
The authorize middleware is a factory function: `authorize('posts', 'delete')` returns a middleware that checks if the request's permission set contains `posts:delete`. It also logs denied access attempts to the audit log for security monitoring.
1// server/middleware/authorize.js2const { db } = require('../db');3const { permissionAuditLog } = require('../schema');45function authorize(resource, action) {6 return async (req, res, next) => {7 const permKey = `${resource}:${action}`;89 if (!req.permissions || !req.permissions.has(permKey)) {10 // Log the denial11 await db.insert(permissionAuditLog).values({12 userId: req.user?.id ?? null,13 action: 'access_denied',14 resource: permKey,15 details: { path: req.path, method: req.method },16 ipAddress: req.ip,17 }).catch(() => {}); // Don't fail the request if logging fails1819 return res.status(403).json({20 error: `Permission denied: ${permKey}`,21 required: permKey,22 });23 }2425 // Log the successful access for sensitive resources26 if (['users', 'roles', 'permissions'].includes(resource)) {27 await db.insert(permissionAuditLog).values({28 userId: req.user.id,29 action: `${resource}:${action}`,30 resource,31 details: { path: req.path },32 ipAddress: req.ip,33 }).catch(() => {});34 }3536 next();37 };38}3940module.exports = { authorize };4142// Usage examples:43// const { authenticate } = require('../middleware/authenticate');44// const { authorize } = require('../middleware/authorize');45//46// app.get('/api/posts', authenticate, authorize('posts', 'read'), handler);47// app.post('/api/posts', authenticate, authorize('posts', 'create'), handler);48// app.delete('/api/posts/:id', authenticate, authorize('posts', 'delete'), handler);Expected result: Calling a protected route without the required permission returns HTTP 403 with the required permission key. The denial is recorded in the audit log.
Build the role permission matrix route
The bulk update route for role permissions receives an array of permission IDs and replaces all current role_permissions rows for that role in a single transaction. This is what powers the permission matrix checkboxes in the admin UI.
1// server/routes/roles.js2const express = require('express');3const { db } = require('../db');4const { roles, permissions, rolePermissions } = require('../schema');5const { eq, inArray } = require('drizzle-orm');67const router = express.Router();89// GET /api/roles/:id/permissions10router.get('/api/roles/:id/permissions', async (req, res) => {11 const roleId = parseInt(req.params.id);12 const rows = await db13 .select({ permissionId: rolePermissions.permissionId })14 .from(rolePermissions)15 .where(eq(rolePermissions.roleId, roleId));1617 res.json(rows.map(r => r.permissionId));18});1920// PUT /api/roles/:id/permissions — replace all permissions for role21router.put('/api/roles/:id/permissions', express.json(), async (req, res) => {22 const roleId = parseInt(req.params.id);23 const { permissionIds } = req.body; // array of permission IDs to grant2425 // Verify role exists and is not being improperly modified26 const [role] = await db.select().from(roles)27 .where(eq(roles.id, roleId)).limit(1);2829 if (!role) return res.status(404).json({ error: 'Role not found' });3031 // Replace in a transaction: delete old, insert new32 await db.transaction(async (tx) => {33 await tx.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId));3435 if (permissionIds.length > 0) {36 await tx.insert(rolePermissions)37 .values(permissionIds.map(pid => ({ roleId, permissionId: pid })))38 .onConflictDoNothing();39 }40 });4142 res.json({ updated: permissionIds.length });43});4445// DELETE /api/roles/:id — only non-system roles46router.delete('/api/roles/:id', async (req, res) => {47 const roleId = parseInt(req.params.id);48 const [role] = await db.select().from(roles)49 .where(eq(roles.id, roleId)).limit(1);5051 if (!role) return res.status(404).json({ error: 'Role not found' });52 if (role.isSystem) return res.status(400).json({ error: 'Cannot delete system roles' });5354 await db.delete(roles).where(eq(roles.id, roleId));55 res.json({ deleted: true });56});5758module.exports = router;Pro tip: Mark the default admin, editor, and viewer roles with is_system=true in the seed data. This prevents accidental deletion via the admin UI.
Seed default roles and deploy on Autoscale
The seed script runs once on first deployment to create the default roles and populate the permissions table with all resource-action pairs. Check if the roles table is empty before inserting to make the seed idempotent. Deploy on Autoscale — permission checks are fast with the in-memory cache and cold starts are brief.
1// server/seed.js — run on startup if roles table is empty2const { db } = require('./db');3const { roles, permissions, rolePermissions } = require('./schema');4const { count } = require('drizzle-orm');56const RESOURCES = ['posts', 'users', 'orders', 'reports']; // your app's resources7const ACTIONS = ['create', 'read', 'update', 'delete'];89async function seedIfEmpty() {10 const [{ value }] = await db.select({ value: count() }).from(roles);11 if (Number(value) > 0) return; // already seeded1213 console.log('[seed] Seeding roles and permissions...');1415 // Insert all permissions16 const allPerms = RESOURCES.flatMap(r => ACTIONS.map(a => ({ resource: r, action: a })));17 const insertedPerms = await db.insert(permissions).values(allPerms)18 .onConflictDoNothing().returning({ id: permissions.id });1920 // Insert default roles21 const [adminRole] = await db.insert(roles)22 .values({ name: 'admin', description: 'Full access', isSystem: true })23 .returning();24 const [editorRole] = await db.insert(roles)25 .values({ name: 'editor', description: 'Create, read, and edit', isSystem: true })26 .returning();27 const [viewerRole] = await db.insert(roles)28 .values({ name: 'viewer', description: 'Read-only access', isSystem: true })29 .returning();3031 // Admin gets all permissions32 const allPermIds = await db.select({ id: permissions.id }).from(permissions);33 await db.insert(rolePermissions)34 .values(allPermIds.map(p => ({ roleId: adminRole.id, permissionId: p.id })))35 .onConflictDoNothing();3637 // Editor gets create/read/update38 const editorPerms = await db.select({ id: permissions.id }).from(permissions)39 .where(require('drizzle-orm').ne(permissions.action, 'delete'));40 await db.insert(rolePermissions)41 .values(editorPerms.map(p => ({ roleId: editorRole.id, permissionId: p.id })))42 .onConflictDoNothing();4344 // Viewer gets read only45 const readerPerms = await db.select({ id: permissions.id }).from(permissions)46 .where(require('drizzle-orm').eq(permissions.action, 'read'));47 await db.insert(rolePermissions)48 .values(readerPerms.map(p => ({ roleId: viewerRole.id, permissionId: p.id })))49 .onConflictDoNothing();5051 console.log('[seed] Done.');52}5354module.exports = { seedIfEmpty };5556// In server/index.js: call await seedIfEmpty() before app.listen()Expected result: On first startup, the roles, permissions, and role_permissions tables are populated with defaults. Subsequent restarts skip the seed. The admin UI shows the permission matrix correctly.
Complete code
1const { db } = require('../db');2const { users, userRoles, rolePermissions, permissions, roles } = require('../schema');3const { eq } = require('drizzle-orm');45async function authenticate(req, res, next) {6 const replitUserId = req.headers['x-replit-user-id'];7 const replitUserName = req.headers['x-replit-user-name'] || 'unknown';89 if (!replitUserId) {10 return res.status(401).json({ error: 'Authentication required' });11 }1213 try {14 // Load or auto-create user15 let [user] = await db.select().from(users)16 .where(eq(users.userId, replitUserId)).limit(1);1718 if (!user) {19 [user] = await db.insert(users)20 .values({ userId: replitUserId, email: `${replitUserName}@replit`, displayName: replitUserName })21 .returning();2223 // Assign default viewer role24 const [viewerRole] = await db.select().from(roles)25 .where(eq(roles.name, 'viewer')).limit(1);2627 if (viewerRole) {28 await db.insert(userRoles)29 .values({ userId: user.id, roleId: viewerRole.id, grantedBy: 'system' })30 .onConflictDoNothing();31 }32 }3334 if (!user.isActive) {35 return res.status(403).json({ error: 'Account is disabled' });36 }3738 // Build effective permissions from all assigned roles39 const rows = await db40 .select({ resource: permissions.resource, action: permissions.action })41 .from(userRoles)42 .innerJoin(rolePermissions, eq(userRoles.roleId, rolePermissions.roleId))43 .innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))44 .where(eq(userRoles.userId, user.id));4546 // O(1) lookup: 'resource:action' keys47 req.user = user;48 req.permissions = new Set(rows.map(r => `${r.resource}:${r.action}`));4950 next();51 } catch (err) {52 console.error('[authenticate] error:', err.message);53 return res.status(500).json({ error: 'Authentication failed' });54 }55}5657module.exports = { authenticate };Customization ideas
Scoped roles for multi-tenant apps
The user_roles table has a `scope` column. Set scope to an organization or project ID when assigning a role. Modify the authenticate middleware to filter user_roles by the current request's scope (from a header or URL param), enabling per-organization permission isolation.
Permission request workflow
Add a `permission_requests` table where users can request elevated access. Admins review pending requests via a new UI tab and grant or deny them. Accepted requests auto-assign the requested role and log to the audit log.
IP-based rate limiting for admin routes
Track failed authorization attempts per IP in the audit log. After 10 failed attempts in 5 minutes, temporarily block that IP from admin routes. Use an in-memory Map for the counter (reset on restart) — sufficient for most abuse scenarios.
Permission export for documentation
Add GET /api/admin/permissions/matrix that returns the full role-permission matrix as a JSON or CSV report. This is useful for security audits and onboarding documentation.
Common pitfalls
Pitfall: Permission checks hit the database on every single request
How to avoid: Store the permission Set on `req.permissions` within the request (no extra DB calls). For high traffic, add a 60-second in-memory cache: `const cache = new Map()` keyed by user.id, storing the permissions Set with a timestamp.
Pitfall: System roles get deleted accidentally
How to avoid: Add `if (role.isSystem) return res.status(400).json({ error: 'Cannot delete system roles' })` before the delete operation. Mark admin, editor, and viewer roles with is_system=true in the seed data.
Pitfall: Removing a role from a user doesn't immediately revoke their access
How to avoid: Since the authenticate middleware rebuilds permissions from the database on every request, revocations take effect immediately with no cache to invalidate.
Best practices
- Store permissions as a Set of 'resource:action' strings in req.permissions — O(1) lookup is faster than filtering an array on every authorization check
- Mark built-in roles with is_system=true and block deletion of system roles in the route handler
- Seed the database on first startup (check row count before inserting) — this ensures default roles and permissions are always present without manual setup
- Log denied access attempts to the audit log — it's the first thing you'll check when a user reports 'I can't access X'
- Use Drizzle Studio (open from the Database tool) to inspect user_roles and role_permissions rows during debugging
- Add a GET /api/me/permissions route that returns the current user's permission set — useful for the frontend to hide/show UI elements based on access
- Test the full permission matrix after deployment by logging in with different user accounts assigned different roles
AI prompts to try
Copy these prompts to build this project faster.
I'm building an RBAC system with Express.js and PostgreSQL using Drizzle ORM on Replit. My schema has users, roles, permissions (resource + action), role_permissions, and user_roles tables. Help me optimize the authenticate middleware that builds a user's effective permissions. Currently it runs a 3-table JOIN on every request. Explain the trade-offs between: (1) rebuilding from DB on every request, (2) a 60-second in-memory Map cache per user, and (3) storing permissions in a JWT token. For each approach, describe the invalidation strategy when an admin changes a user's role.
Add a permission matrix UI component to my RBAC React admin panel. The matrix should show a grid where rows are resources (posts, users, orders, reports) and columns are CRUD actions (create, read, update, delete). Each cell is a checkbox. When viewing a role, pre-check the cells for its current permissions. When the admin checks/unchecks cells and clicks Save, call PUT /api/roles/:id/permissions with the array of checked permission IDs. Show a loading indicator during the save and a success toast when done.
Frequently asked questions
What's the difference between authentication and authorization in this system?
Authentication answers 'who are you?' — handled by Replit Auth, which provides the user's identity via request headers. Authorization answers 'what are you allowed to do?' — handled by this RBAC system's authorize() middleware, which checks if the authenticated user's roles include the required permission.
Can a user have multiple roles?
Yes. The user_roles table is a many-to-many join. A user can be assigned both 'editor' and 'billing-viewer' roles, and their effective permissions are the union of both roles' permission sets.
What happens if an admin removes a role from a user — do they lose access immediately?
Yes, immediately. The authenticate middleware rebuilds the permission Set from the database on every request. There's no JWT or session that caches permissions between requests, so revocations take effect on the user's very next API call.
Should I use Autoscale or Reserved VM for this app?
Autoscale is fine for most permission management systems. The permission check is fast (one cached query per request) and the admin UI is low-traffic. Reserved VM is only needed if you're building a high-concurrency app where the cold start latency on permission checks is noticeable.
How do I add a new resource to the permission system?
Add the new resource name to the RESOURCES array in server/seed.js and run the seed (it's idempotent — it checks for existing rows). This inserts four new permission rows (create/read/update/delete). Then update the admin role's permissions via the admin UI or a migration script.
Can I use this RBAC system in an existing Express app?
Yes. Copy the middleware files, run the seed script to create the roles and permissions tables, and add `authenticate, authorize('resource', 'action')` to any route you want to protect. The system is designed to be pluggable.
How do I make the first user automatically an admin?
In the seed.js, after seeding default roles, add logic: after inserting the first user row, check if any admin user_roles exist; if not, assign the admin role to this user. Or use an environment variable `ADMIN_USER_ID` stored in Replit Secrets to identify which Replit user gets admin on first login.
Can RapidDev help me build a custom permission system?
Yes. RapidDev has built 600+ apps including multi-tenant SaaS platforms with complex RBAC, attribute-based access control, and compliance audit trails. Book a free consultation at rapidevelopers.com.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation