Securityintermediate

Authorization Patterns — RBAC, PBAC, ABAC & API Access Control

Learn how to implement authorization in web applications. Covers Role-Based, Permission-Based, and Attribute-Based access control with practical code examples for frontend and API.

14 min read·Published Apr 18, 2026
securityauthorizationrbacaccess-control

Authentication vs Authorization (Quick Recap)

Authentication answers "who are you?" — it verifies identity. Authorization answers "what can you do?" — it verifies permissions. Authentication happens first. Authorization happens every time the authenticated user tries to access a resource or perform an action.

 Request arrives
      |
      v
 [Authentication] -- "Who is this?" --> user = { id: 42, role: 'editor' }
      |
      v
 [Authorization]  -- "Can user 42 edit article 7?" --> allow / deny
      |
      v
 [Execute action or return 403]

A properly authenticated user can still be unauthorized. An admin can access the admin panel; a regular user cannot. Both are authenticated, but they have different authorizations.

Role-Based Access Control (RBAC)

RBAC is the most common authorization model. Users are assigned roles, and roles have predefined sets of permissions. Instead of assigning permissions directly to users, you assign roles.

+------------------+--------------------------------------------------+
| Role             | Permissions                                      |
+------------------+--------------------------------------------------+
| viewer           | read:articles, read:comments                     |
| editor           | read:articles, write:articles, read:comments,    |
|                  | write:comments                                   |
| admin            | read:articles, write:articles, delete:articles,  |
|                  | read:comments, write:comments, delete:comments,  |
|                  | manage:users                                     |
| super_admin      | * (all permissions)                              |
+------------------+--------------------------------------------------+

RBAC Implementation

// roles.js — Define roles and their permissions
const ROLES = {
  viewer: {
    permissions: ['read:articles', 'read:comments'],
  },
  editor: {
    permissions: [
      'read:articles', 'write:articles',
      'read:comments', 'write:comments',
    ],
  },
  admin: {
    permissions: [
      'read:articles', 'write:articles', 'delete:articles',
      'read:comments', 'write:comments', 'delete:comments',
      'manage:users',
    ],
  },
  super_admin: {
    permissions: ['*'],
  },
};

function hasPermission(userRole, requiredPermission) {
  const role = ROLES[userRole];
  if (!role) return false;

  // Super admin wildcard
  if (role.permissions.includes('*')) return true;

  return role.permissions.includes(requiredPermission);
}

function hasAnyPermission(userRole, requiredPermissions) {
  return requiredPermissions.some(perm => hasPermission(userRole, perm));
}

function hasAllPermissions(userRole, requiredPermissions) {
  return requiredPermissions.every(perm => hasPermission(userRole, perm));
}

module.exports = { ROLES, hasPermission, hasAnyPermission, hasAllPermissions };

RBAC Middleware for Express

const { hasPermission } = require('./roles');

// Middleware factory — checks for a specific permission
function requirePermission(permission) {
  return (req, res, next) => {
    const userRole = req.user?.role;

    if (!userRole) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    if (!hasPermission(userRole, permission)) {
      return res.status(403).json({
        error: 'Forbidden',
        detail: `Role "${userRole}" lacks permission "${permission}"`,
      });
    }

    next();
  };
}

// Usage in routes
app.get('/api/articles', requirePermission('read:articles'), (req, res) => {
  // Only users with read:articles can reach here
  const articles = await db.articles.findAll();
  res.json(articles);
});

app.post('/api/articles', requirePermission('write:articles'), (req, res) => {
  const article = await db.articles.create(req.body);
  res.json(article);
});

app.delete('/api/articles/:id', requirePermission('delete:articles'), (req, res) => {
  await db.articles.delete(req.params.id);
  res.json({ success: true });
});

app.get('/api/admin/users', requirePermission('manage:users'), (req, res) => {
  const users = await db.users.findAll();
  res.json(users);
});

RBAC with Hierarchical Roles

Some systems use role hierarchy where higher roles inherit all permissions from lower roles:

// Role hierarchy: super_admin > admin > editor > viewer
const ROLE_HIERARCHY = {
  viewer: 0,
  editor: 1,
  admin: 2,
  super_admin: 3,
};

function hasMinimumRole(userRole, requiredRole) {
  const userLevel = ROLE_HIERARCHY[userRole];
  const requiredLevel = ROLE_HIERARCHY[requiredRole];

  if (userLevel === undefined || requiredLevel === undefined) return false;
  return userLevel >= requiredLevel;
}

// Middleware
function requireRole(minimumRole) {
  return (req, res, next) => {
    if (!hasMinimumRole(req.user?.role, minimumRole)) {
      return res.status(403).json({ error: 'Insufficient role' });
    }
    next();
  };
}

// Usage
app.delete('/api/articles/:id', requireRole('admin'), handler);
app.get('/api/admin/dashboard', requireRole('super_admin'), handler);

Permission-Based Access Control (PBAC)

PBAC assigns permissions directly to users instead of through roles. This offers finer granularity — a user can have any combination of permissions regardless of a predefined role.

User "alice":
  permissions: ['read:articles', 'write:articles', 'read:analytics']

User "bob":
  permissions: ['read:articles', 'write:comments', 'manage:tags']

PBAC is more flexible than RBAC but harder to manage at scale. You end up with many individual permission assignments instead of a few role assignments.

PBAC Implementation

// Database schema (simplified)
// users: { id, email, passwordHash }
// user_permissions: { userId, permission }

async function getUserPermissions(userId) {
  const rows = await db.query(
    'SELECT permission FROM user_permissions WHERE user_id = $1',
    [userId]
  );
  return rows.map(r => r.permission);
}

function requirePermission(permission) {
  return async (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    const permissions = await getUserPermissions(req.user.id);

    if (!permissions.includes(permission) && !permissions.includes('*')) {
      return res.status(403).json({ error: 'Forbidden' });
    }

    next();
  };
}

// Admin endpoint to grant/revoke permissions
app.post('/api/admin/permissions', requirePermission('manage:permissions'), async (req, res) => {
  const { userId, permission, action } = req.body;

  if (action === 'grant') {
    await db.query(
      'INSERT INTO user_permissions (user_id, permission) VALUES ($1, $2) ON CONFLICT DO NOTHING',
      [userId, permission]
    );
  } else if (action === 'revoke') {
    await db.query(
      'DELETE FROM user_permissions WHERE user_id = $1 AND permission = $2',
      [userId, permission]
    );
  }

  res.json({ success: true });
});

Hybrid: RBAC + PBAC

In practice, most systems combine roles with individual permission overrides:

async function getEffectivePermissions(userId) {
  const user = await db.users.findById(userId);

  // Start with role permissions
  const rolePerms = ROLES[user.role]?.permissions || [];

  // Add individual permissions
  const individualPerms = await getUserPermissions(userId);

  // Merge and deduplicate
  const allPerms = [...new Set([...rolePerms, ...individualPerms])];

  return allPerms;
}

function requirePermission(permission) {
  return async (req, res, next) => {
    const perms = await getEffectivePermissions(req.user.id);

    if (perms.includes('*') || perms.includes(permission)) {
      return next();
    }

    res.status(403).json({ error: 'Forbidden' });
  };
}

Attribute-Based Access Control (ABAC)

ABAC makes authorization decisions based on attributes of the user, the resource, the action, and the environment. It is the most flexible (and most complex) model.

Decision = f(user attributes, resource attributes, action, environment)

Example policy:
  IF user.department == "engineering"
  AND resource.type == "code-repository"
  AND action == "read"
  AND environment.time is within business hours
  THEN allow
+------------------+-----------+-----------+-----------+
| Model            | RBAC      | PBAC      | ABAC      |
+------------------+-----------+-----------+-----------+
| Granularity      | Coarse    | Medium    | Fine      |
| Complexity       | Low       | Medium    | High      |
| Scalability      | Easy      | Moderate  | Hard      |
| Dynamic rules    | No        | No        | Yes       |
| Context-aware    | No        | No        | Yes       |
| Best for         | Simple    | Moderate  | Complex   |
|                  | apps      | apps      | enterprise|
+------------------+-----------+-----------+-----------+

ABAC Implementation

// Policy engine
function evaluatePolicy(policy, context) {
  const { user, resource, action, environment } = context;

  for (const condition of policy.conditions) {
    const { attribute, operator, value, source } = condition;

    // Get the actual value from the context
    let actual;
    switch (source) {
      case 'user':
        actual = user[attribute];
        break;
      case 'resource':
        actual = resource[attribute];
        break;
      case 'environment':
        actual = environment[attribute];
        break;
      default:
        return false;
    }

    // Evaluate condition
    switch (operator) {
      case 'equals':
        if (actual !== value) return false;
        break;
      case 'in':
        if (!value.includes(actual)) return false;
        break;
      case 'contains':
        if (!actual?.includes(value)) return false;
        break;
      case 'greaterThan':
        if (actual <= value) return false;
        break;
      case 'lessThan':
        if (actual >= value) return false;
        break;
      default:
        return false;
    }
  }

  // All conditions passed
  return true;
}

// Define policies
const policies = [
  {
    name: 'Engineers can read repos during business hours',
    action: 'read',
    effect: 'allow',
    conditions: [
      { source: 'user', attribute: 'department', operator: 'equals', value: 'engineering' },
      { source: 'resource', attribute: 'type', operator: 'equals', value: 'repository' },
      { source: 'environment', attribute: 'hour', operator: 'greaterThan', value: 8 },
      { source: 'environment', attribute: 'hour', operator: 'lessThan', value: 18 },
    ],
  },
  {
    name: 'Owners can do anything to their own resources',
    action: '*',
    effect: 'allow',
    conditions: [
      { source: 'user', attribute: 'id', operator: 'equals', value: '$resource.ownerId' },
    ],
  },
];

// Authorization middleware
function authorize(action) {
  return async (req, res, next) => {
    const context = {
      user: req.user,
      resource: req.resource, // Set by a previous middleware
      action,
      environment: {
        hour: new Date().getHours(),
        ip: req.ip,
        dayOfWeek: new Date().getDay(),
      },
    };

    const allowed = policies.some(policy => {
      if (policy.action !== '*' && policy.action !== action) return false;
      if (policy.effect !== 'allow') return false;
      return evaluatePolicy(policy, context);
    });

    if (!allowed) {
      return res.status(403).json({ error: 'Access denied by policy' });
    }

    next();
  };
}

Frontend Authorization: UI Enforcement

Authorization must be enforced on the server. The frontend enforces authorization for UX purposes only — hiding or disabling elements the user should not see. The server must re-check permissions on every API request because frontend checks can be bypassed.

 Frontend Authorization           Server Authorization
 (UX only — can be bypassed)      (Security — cannot be bypassed)
 ----------------------------     ----------------------------
 Hide "Delete" button if          Return 403 if user lacks
 user is not admin                delete permission
                                  (regardless of what UI shows)

React Authorization Components

import { createContext, useContext, ReactNode } from 'react';

// Types
type User = {
  id: string;
  role: string;
  permissions: string[];
};

type AuthContextValue = {
  user: User | null;
  hasPermission: (permission: string) => boolean;
  hasRole: (role: string) => boolean;
  hasMinimumRole: (role: string) => boolean;
};

// Context
const AuthContext = createContext<AuthContextValue>({
  user: null,
  hasPermission: () => false,
  hasRole: () => false,
  hasMinimumRole: () => false,
});

const ROLE_HIERARCHY: Record<string, number> = {
  viewer: 0,
  editor: 1,
  admin: 2,
  super_admin: 3,
};

// Provider
export function AuthProvider({
  user,
  children,
}: {
  user: User | null;
  children: ReactNode;
}) {
  const value: AuthContextValue = {
    user,
    hasPermission: (perm) => {
      if (!user) return false;
      return user.permissions.includes('*') || user.permissions.includes(perm);
    },
    hasRole: (role) => user?.role === role,
    hasMinimumRole: (role) => {
      if (!user) return false;
      return (ROLE_HIERARCHY[user.role] ?? -1) >= (ROLE_HIERARCHY[role] ?? Infinity);
    },
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export const useAuth = () => useContext(AuthContext);

Gate Components: Show/Hide Based on Permissions

import { ReactNode } from 'react';
import { useAuth } from './AuthProvider';

type PermissionGateProps = {
  permission: string;
  children: ReactNode;
  fallback?: ReactNode;
};

export function PermissionGate({ permission, children, fallback = null }: PermissionGateProps) {
  const { hasPermission } = useAuth();

  if (!hasPermission(permission)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

type RoleGateProps = {
  minimumRole: string;
  children: ReactNode;
  fallback?: ReactNode;
};

export function RoleGate({ minimumRole, children, fallback = null }: RoleGateProps) {
  const { hasMinimumRole } = useAuth();

  if (!hasMinimumRole(minimumRole)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

// Usage in components
function ArticlePage({ article }) {
  return (
    <div>
      <h1>{article.title}</h1>
      <p>{article.content}</p>

      <PermissionGate permission="write:articles">
        <button onClick={() => editArticle(article.id)}>Edit</button>
      </PermissionGate>

      <PermissionGate permission="delete:articles">
        <button onClick={() => deleteArticle(article.id)}>Delete</button>
      </PermissionGate>

      <RoleGate minimumRole="admin">
        <AdminPanel articleId={article.id} />
      </RoleGate>
    </div>
  );
}

Hidden vs Disabled UI Elements

Should unauthorized elements be hidden or disabled? Each approach has trade-offs:

+-------------------+-----------------------------------+-----------------------------------+
| Approach          | Pros                              | Cons                              |
+-------------------+-----------------------------------+-----------------------------------+
| Hidden            | Cleaner UI, no confusion          | Users don't know feature exists,  |
|                   |                                   | may not request access            |
| Disabled          | Users see what they could access,  | Cluttered UI, must explain why    |
|                   | encourages upgrade/request access  | disabled                          |
| Hidden + tooltip  | Best of both — hinted on hover    | More complex implementation       |
+-------------------+-----------------------------------+-----------------------------------+

General guideline:

  • Hide features that the user's role should never access (e.g., admin panel for viewers)
  • Disable features the user could theoretically access with a different plan/permission (e.g., premium features)
// Disabled button with explanation
function FeatureButton({ hasAccess, onClick, children }) {
  if (!hasAccess) {
    return (
      <div title="Upgrade to Pro to access this feature">
        <button disabled className="opacity-50 cursor-not-allowed">
          {children}
        </button>
      </div>
    );
  }

  return <button onClick={onClick}>{children}</button>;
}

Authorization in API Design

Resource-Level Authorization

Beyond role checks, APIs must verify the user has access to the specific resource they are requesting:

// Check that user owns or has access to the resource
app.put('/api/articles/:id', requirePermission('write:articles'), async (req, res) => {
  const article = await db.articles.findById(req.params.id);

  if (!article) {
    return res.status(404).json({ error: 'Not found' });
  }

  // Resource-level check: can this user edit THIS article?
  if (article.authorId !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Cannot edit articles by other authors' });
  }

  const updated = await db.articles.update(req.params.id, req.body);
  res.json(updated);
});

Preventing IDOR (Insecure Direct Object Reference)

IDOR is a common authorization flaw where users can access other users' data by changing an ID in the URL:

// VULNERABLE — No ownership check
app.get('/api/users/:id/profile', async (req, res) => {
  const profile = await db.profiles.findByUserId(req.params.id);
  res.json(profile); // Any authenticated user can view any profile
});

// SAFE — Verify ownership or permission
app.get('/api/users/:id/profile', async (req, res) => {
  const requestedId = req.params.id;

  // Users can only access their own profile, admins can access any
  if (requestedId !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }

  const profile = await db.profiles.findByUserId(requestedId);
  res.json(profile);
});

Scoping Database Queries

Instead of fetching all data and filtering, scope queries to the user's access level:

// Scoped queries — user only sees their own data
app.get('/api/orders', async (req, res) => {
  let orders;

  if (req.user.role === 'admin') {
    orders = await db.orders.findAll();
  } else {
    // Non-admins only see their own orders
    orders = await db.orders.findByUserId(req.user.id);
  }

  res.json(orders);
});

// Scoped query — team-level access
app.get('/api/projects', async (req, res) => {
  const userTeams = await db.teamMembers.findTeamsByUserId(req.user.id);
  const teamIds = userTeams.map(t => t.teamId);

  const projects = await db.projects.findByTeamIds(teamIds);
  res.json(projects);
});

Authorization in Next.js

API Route Authorization

// lib/auth.ts
import type { NextApiRequest, NextApiResponse, NextApiHandler } from 'next';

type AuthOptions = {
  permissions?: string[];
  roles?: string[];
  minimumRole?: string;
};

export function withAuth(handler: NextApiHandler, options: AuthOptions = {}) {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    // Step 1: Authenticate
    const user = await getUserFromRequest(req);
    if (!user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    // Step 2: Check role
    if (options.minimumRole) {
      const userLevel = ROLE_HIERARCHY[user.role] ?? -1;
      const requiredLevel = ROLE_HIERARCHY[options.minimumRole] ?? Infinity;
      if (userLevel < requiredLevel) {
        return res.status(403).json({ error: 'Insufficient role' });
      }
    }

    // Step 3: Check specific permissions
    if (options.permissions?.length) {
      const hasAll = options.permissions.every(
        perm => user.permissions.includes('*') || user.permissions.includes(perm)
      );
      if (!hasAll) {
        return res.status(403).json({ error: 'Missing required permissions' });
      }
    }

    // Attach user to request
    (req as any).user = user;
    return handler(req, res);
  };
}

// Usage
// pages/api/admin/users.ts
export default withAuth(
  async (req, res) => {
    const users = await db.users.findAll();
    res.json(users);
  },
  { minimumRole: 'admin' }
);

Page-Level Authorization (Server-Side)

// pages/admin/dashboard.tsx
import type { GetServerSideProps } from 'next';

export const getServerSideProps: GetServerSideProps = async (context) => {
  const user = await getUserFromSession(context.req);

  if (!user) {
    return { redirect: { destination: '/login', permanent: false } };
  }

  if (user.role !== 'admin' && user.role !== 'super_admin') {
    return { redirect: { destination: '/unauthorized', permanent: false } };
  }

  return { props: { user } };
};

export default function AdminDashboard({ user }) {
  return <div>Welcome, {user.email}</div>;
}

Common Authorization Mistakes

+------+----------------------------------------+------------------------------------------+
| #    | Mistake                                | Fix                                      |
+------+----------------------------------------+------------------------------------------+
| 1    | Frontend-only authorization             | Always enforce on server                 |
| 2    | Checking role but not resource owner    | Add resource-level ownership checks      |
| 3    | Sequential ID exposure (IDOR)          | Use UUIDs + ownership verification       |
| 4    | Missing auth on new endpoints          | Default-deny — all routes require auth   |
| 5    | Overly broad role permissions          | Principle of least privilege             |
| 6    | Hardcoded admin checks                 | Use configurable role/permission system  |
| 7    | No logging of authorization failures   | Log all 403s for security monitoring     |
+------+----------------------------------------+------------------------------------------+

Authorization Checklist

+------+----------------------------------------------+----------+
| #    | Check                                        | Status   |
+------+----------------------------------------------+----------+
| 1    | All API endpoints have authorization checks   | [ ]      |
| 2    | Resource-level ownership verified              | [ ]      |
| 3    | No IDOR vulnerabilities (tested)              | [ ]      |
| 4    | Frontend gates are UX only, not security       | [ ]      |
| 5    | Server denies by default (allowlist approach)  | [ ]      |
| 6    | Role/permission changes take effect immediately| [ ]      |
| 7    | Database queries scoped to user access level   | [ ]      |
| 8    | 403 responses logged for monitoring            | [ ]      |
| 9    | Principle of least privilege applied           | [ ]      |
| 10   | Authorization logic centralized (not scattered)| [ ]      |
+------+----------------------------------------------+----------+

Summary

Authorization controls what authenticated users can do. The three main models are:

  1. RBAC — Assign roles, roles have permissions. Simple, scalable, covers most use cases.
  2. PBAC — Assign permissions directly to users. More granular, harder to manage.
  3. ABAC — Decisions based on user/resource/environment attributes. Most flexible, most complex.

Most applications start with RBAC and add PBAC overrides as needed. ABAC is for complex enterprise scenarios.

Cardinal rules:

  • Server enforces — Frontend hides/disables for UX only. Every API endpoint must check permissions.
  • Resource-level checks — It is not enough to check "can this user edit articles." Check "can this user edit THIS article."
  • Default deny — If no rule explicitly allows access, deny it.
  • Least privilege — Users get the minimum permissions they need. No more.
  • Centralize logic — Authorization checks in one place (middleware/service), not scattered across handlers.

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles