Difficulty: Intermediate | Read time: 14 min | Framework: CodeIgniter 4
Why RBAC Matters in Real Projects
Every project I've built beyond a simple blog has needed access control. Not just "is the user logged in?" — but "can this user do this specific thing?"
Without a proper RBAC system you end up with scattered if ($user->is_admin) checks everywhere, and one missed check means a security hole.
In this tutorial I'll show you how I built a clean, reusable RBAC system for an internal CRM — covering roles, permissions, middleware, and view-level gating.
What we're building: A role and permission system where each user has a role (Admin, Manager, Staff), each role has specific permissions, and access is enforced at the route, controller, and view level.
Database Structure
Three tables — users, roles, and permissions.
CREATE TABLE `tblroles` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL UNIQUE,
`description` VARCHAR(255),
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE `tblpermissions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL UNIQUE,
`module` VARCHAR(100),
`description` VARCHAR(255)
);
CREATE TABLE `tblrole_permissions` (
`role_id` INT NOT NULL,
`permission_id` INT NOT NULL,
PRIMARY KEY (`role_id`, `permission_id`),
FOREIGN KEY (`role_id`) REFERENCES `tblroles`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`permission_id`) REFERENCES `tblpermissions`(`id`) ON DELETE CASCADE
);
-- Add role_id to your staff/users table
ALTER TABLE `tblstaff` ADD COLUMN `role_id` INT NULL REFERENCES `tblroles`(`id`);
Seed Some Default Data
-- Roles
INSERT INTO `tblroles` (`name`, `description`) VALUES
('Admin', 'Full access to everything'),
('Manager', 'Can manage staff and view reports'),
('Staff', 'Limited access to assigned tasks only');
-- Permissions
INSERT INTO `tblpermissions` (`name`, `module`) VALUES
('view_dashboard', 'dashboard'),
('manage_users', 'users'),
('view_reports', 'reports'),
('export_reports', 'reports'),
('manage_tickets', 'tickets'),
('view_tickets', 'tickets'),
('manage_leads', 'leads'),
('view_leads', 'leads');
-- Assign all permissions to Admin (role_id = 1)
INSERT INTO `tblrole_permissions` (`role_id`, `permission_id`)
SELECT 1, id FROM `tblpermissions`;
-- Manager gets view + manage reports + view leads
INSERT INTO `tblrole_permissions` (`role_id`, `permission_id`)
SELECT 2, id FROM `tblpermissions`
WHERE `name` IN ('view_dashboard','view_reports','export_reports','view_leads','view_tickets');
-- Staff gets minimal access
INSERT INTO `tblrole_permissions` (`role_id`, `permission_id`)
SELECT 3, id FROM `tblpermissions`
WHERE `name` IN ('view_dashboard','view_tickets','view_leads');
The Permission Helper
This is the core of the system — a single function you'll call everywhere.
<?php
// app/Helpers/permission_helper.php
/**
* Check if the currently logged-in user has a specific permission.
*/
function has_permission(string $permission): bool
{
$session = session();
$permissions = $session->get('permissions') ?? [];
return in_array($permission, $permissions);
}
/**
* Check if the user has ANY of the given permissions.
*/
function has_any_permission(array $permissions): bool
{
foreach ($permissions as $permission) {
if (has_permission($permission)) {
return true;
}
}
return false;
}
/**
* Abort with 403 if user doesn't have permission.
*/
function require_permission(string $permission): void
{
if (!has_permission($permission)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
}
Load it in app/Config/Autoload.php:
public $helpers = ['url', 'form', 'permission'];
Load Permissions Into Session at Login
After the user logs in, load their permissions into the session so every check is fast (no DB query per request).
<?php
// app/Controllers/Auth.php (your login controller)
private function loadUserPermissions(int $staffId): void
{
$db = \Config\Database::connect();
$permissions = $db->table('tblrole_permissions rp')
->select('p.name')
->join('tblpermissions p', 'p.id = rp.permission_id')
->join('tblstaff s', 's.role_id = rp.role_id')
->where('s.staffid', $staffId)
->get()
->getResultArray();
$permissionNames = array_column($permissions, 'name');
session()->set([
'staff_id' => $staffId,
'permissions' => $permissionNames,
'role' => $this->getUserRole($staffId),
]);
}
private function getUserRole(int $staffId): string
{
$db = \Config\Database::connect();
$row = $db->table('tblstaff s')
->select('r.name')
->join('tblroles r', 'r.id = s.role_id')
->where('s.staffid', $staffId)
->get()->getRowArray();
return $row['name'] ?? 'Staff';
}
Call $this->loadUserPermissions($staffId) right after successful login verification.
The RBAC Middleware (Filter)
Create a filter that protects entire route groups.
<?php
// app/Filters/RBACFilter.php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
class RBACFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
// Not logged in at all
if (!session()->get('staff_id')) {
return redirect()->to(base_url('login'));
}
// No permission argument passed — just check login
if (empty($arguments)) {
return;
}
// Check each required permission
$userPermissions = session()->get('permissions') ?? [];
foreach ($arguments as $required) {
if (!in_array($required, $userPermissions)) {
// Redirect to 403 page
return redirect()->to(base_url('errors/permission_denied'));
}
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Nothing needed after
}
}
Register it in app/Config/Filters.php:
public array $aliases = [
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class,
'rbac' => \App\Filters\RBACFilter::class, // <-- add this
];
Protect Routes With the Filter
// app/Config/Routes.php
// Anyone logged in
$routes->group('dashboard', ['filter' => 'rbac'], function ($routes) {
$routes->get('/', 'Dashboard::index');
});
// Only users with manage_users permission
$routes->group('users', ['filter' => 'rbac:manage_users'], function ($routes) {
$routes->get('/', 'Users::index');
$routes->get('create', 'Users::create');
$routes->post('store', 'Users::store');
$routes->get('edit/(:num)', 'Users::edit/$1');
$routes->post('update/(:num)', 'Users::update/$1');
$routes->post('delete/(:num)', 'Users::delete/$1');
});
// Reports — needs view_reports
$routes->group('reports', ['filter' => 'rbac:view_reports'], function ($routes) {
$routes->get('/', 'Reports::index');
$routes->get('export', 'Reports::export', ['filter' => 'rbac:export_reports']);
});
// Tickets
$routes->group('tickets', ['filter' => 'rbac:view_tickets'], function ($routes) {
$routes->get('/', 'Tickets::index');
$routes->get('manage', 'Tickets::manage', ['filter' => 'rbac:manage_tickets']);
});
Controller-Level Check
For finer control inside a controller method:
<?php
// app/Controllers/Reports.php
namespace App\Controllers;
class Reports extends BaseController
{
public function export(): \CodeIgniter\HTTP\ResponseInterface
{
// Extra check at controller level
if (!has_permission('export_reports')) {
return redirect()->to(base_url('errors/permission_denied'));
}
// ... export logic
}
public function delete(int $id): \CodeIgniter\HTTP\ResponseInterface
{
// Use the helper shortcut — throws 404 if no permission
require_permission('manage_reports');
// ... delete logic
}
}
View-Level Gating
Hide buttons and menu items the user can't access:
<!-- Sidebar navigation — show only what the user can access -->
<ul class="sidebar-menu">
<li>
<a href="<?= base_url('dashboard') ?>">
<i class="fas fa-home"></i> Dashboard
</a>
</li>
<?php if (has_permission('manage_users')): ?>
<li>
<a href="<?= base_url('users') ?>">
<i class="fas fa-users"></i> Users
</a>
</li>
<?php endif; ?>
<?php if (has_permission('view_reports')): ?>
<li>
<a href="<?= base_url('reports') ?>">
<i class="fas fa-chart-bar"></i> Reports
</a>
</li>
<?php endif; ?>
<?php if (has_permission('view_tickets')): ?>
<li>
<a href="<?= base_url('tickets') ?>">
<i class="fas fa-ticket-alt"></i> Tickets
</a>
</li>
<?php endif; ?>
</ul>
<!-- Hide export button from users without permission -->
<?php if (has_permission('export_reports')): ?>
<a href="<?= base_url('reports/export') ?>" class="btn btn-success">
<i class="fas fa-download"></i> Export CSV
</a>
<?php endif; ?>
The 403 Permission Denied Page
Create a clean error page instead of crashing:
<?php
// app/Controllers/Errors.php
namespace App\Controllers;
class Errors extends BaseController
{
public function permissionDenied(): string
{
return view('errors/permission_denied');
}
}
<!-- app/Views/errors/permission_denied.php -->
<div class="container text-center py-5">
<div class="mb-4">
<i class="fas fa-lock fa-4x text-danger"></i>
</div>
<h2 class="fw-bold">Access Denied</h2>
<p class="text-muted">
You don't have permission to view this page.<br>
Contact your administrator if you think this is a mistake.
</p>
<a href="<?= base_url('dashboard') ?>" class="btn btn-primary mt-3">
Go to Dashboard
</a>
</div>
Add the route:
$routes->get('errors/permission_denied', 'Errors::permissionDenied');
Common Pitfalls
⚠️ 1. Checking permissions on every request with a DB query
Don't query the DB on every request to check permissions — it kills performance. Load permissions into the session at login and check from there. That's what we did above with session()->get('permissions').
⚠️ 2. Forgetting to refresh permissions after role change
If an admin changes a user's role, that user's session still has old permissions until they log out. Fix this by forcing a session refresh when role changes:
// In your Users controller — after updating role
if ($userId == session()->get('staff_id')) {
$this->loadUserPermissions($userId); // Refresh own session
}
⚠️ 3. Hiding buttons is NOT enough security
Never rely on just hiding buttons in the view. Always check permissions in the controller or filter too. A user can manually type a URL and bypass view-level checks.
⚠️ 4. Route filter not applying
If your filter isn't working, make sure the filter is registered in Filters.php AND CodeIgniter's filters are enabled. Check app/Config/Filters.php for the $globals or $methods array too.
What to Build Next
- ABAC (Attribute-Based Access Control) — go beyond roles, add conditions like "manager can only see their own team's data"
- Role management UI — let admins create/edit roles and assign permissions from a dashboard
- Permission audit log — track who accessed what and when
- API route protection — extend the filter to check Bearer tokens with permissions
Conclusion
A solid RBAC system isn't hard to build in CodeIgniter 4 — it just needs to be done right from the start. Load permissions into the session, use a filter for route groups, and gate at the view level for UI polish.
Your controllers stay clean, your routes are self-documenting, and you never have another scattered is_admin check again.
Follow me for more CodeIgniter production tutorials — 3 new articles every week. 🙌
Senior PHP Developer · 12+ years building production systems on CodeIgniter, Laravel & WordPress