Role-Based Access Control in React: A Practical Guide

Role-Based Access Control in React: A Practical Guide

A hands-on guide to implementing RBAC in React apps โ€” covering roles vs permissions, protected routes, conditional UI, JWT-based role handling, and why backend enforcement is non-negotiable.

tech
August 13, 2026
12 min read

Most React apps eventually need to answer one question: "Is this user allowed to do this?" A junior developer might hide a button. A senior developer builds a system. Role-Based Access Control (RBAC) is that system โ€” a structured way to decide who can see what and who can do what, consistently, across your entire application. This guide skips the textbook theory and gets straight to working code, real patterns, and the security mistakes that quietly break production apps.

What Is RBAC? Roles vs Permissions Explained

RBAC stands for Role-Based Access Control. Instead of assigning individual permissions to every user, you assign users to roles, and roles carry a set of permissions. This makes management scalable โ€” change the role, and every user in that role is updated instantly.

  • Role: A named group that represents a job function โ€” e.g., 'admin', 'editor', 'viewer'.
  • Permission: A specific action a role is allowed to perform โ€” e.g., 'delete:post', 'read:report', 'manage:users'.
  • User: A person assigned one or more roles, inheriting all permissions those roles carry.
RolePermissions
adminread:users, delete:users, manage:settings, publish:post
editorread:users, create:post, edit:post, publish:post
viewerread:users, read:post

The key insight: your code should check permissions, not roles. Checking roles directly (e.g., if role === 'admin') couples your UI to role names. Checking permissions (e.g., if hasPermission('delete:users')) is flexible โ€” you can add a new 'superadmin' role later without touching your component code.

Why Frontend-Only RBAC Is a Security Risk

Here is the most important thing in this entire article: hiding UI elements is NOT security. It is UX. If your API endpoint does not check authorization, any user with a browser's DevTools or a tool like Postman can call it directly โ€” regardless of what your React app shows or hides.

The frontend controls what users see. The backend controls what users can do. Never confuse the two.

Consider this scenario: you hide the 'Delete User' button for non-admins in React. But the DELETE /api/users/:id endpoint has no authorization check. A non-admin opens DevTools, copies the auth token from localStorage, fires the request in Postman, and deletes any user they want. Your React RBAC did nothing. The frontend is a convenience layer โ€” the backend is the enforcer.

  • Always validate roles/permissions on every API request server-side.
  • Treat every incoming request as potentially malicious, regardless of origin.
  • Never store sensitive business logic (e.g., pricing rules, data filters) only in frontend code.
  • Do not trust role data sent from the client โ€” derive it from a verified token on the server.

Setting Up: JWT-Based Role Handling in React

The most common pattern is to embed the user's role (or permissions) inside a JSON Web Token (JWT) issued by your backend at login. The React app decodes the token client-side to drive UI decisions. The backend verifies the token's signature on every request to enforce authorization.

A JWT has three parts: a header, a payload, and a signature. The payload is where roles live. Here is what a decoded JWT payload might look like:

json
{
  "sub": "user_abc123",
  "email": "jane@example.com",
  "role": "editor",
  "permissions": ["read:users", "create:post", "edit:post", "publish:post"],
  "iat": 1716000000,
  "exp": 1716086400
}

Now let's build an AuthContext that decodes this token and makes the user's role and permissions available throughout the app. We'll use the jwt-decode library (install with npm install jwt-decode).

typescript
// src/context/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import { jwtDecode } from 'jwt-decode';

interface DecodedToken {
  sub: string;
  email: string;
  role: string;
  permissions: string[];
  exp: number;
}

interface AuthContextType {
  user: DecodedToken | null;
  login: (token: string) => void;
  logout: () => void;
  hasPermission: (permission: string) => boolean;
}

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<DecodedToken | null>(() => {
    const token = localStorage.getItem('token');
    return token ? jwtDecode<DecodedToken>(token) : null;
  });

  const login = (token: string) => {
    localStorage.setItem('token', token);
    setUser(jwtDecode<DecodedToken>(token));
  };

  const logout = () => {
    localStorage.removeItem('token');
    setUser(null);
  };

  // Check if the current user has a specific permission
  const hasPermission = (permission: string): boolean => {
    if (!user) return false;
    return user.permissions.includes(permission);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, hasPermission }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
  return ctx;
};

Wrap your app with <AuthProvider> in main.tsx or App.tsx. Now every component can call useAuth() to get the current user, check permissions, or trigger login/logout. Notice that hasPermission checks the permissions array โ€” not the role string directly. This keeps your components decoupled from role names.

Protected Routes: Blocking Unauthorized Pages

A protected route is a wrapper component that checks authorization before rendering a page. If the check fails, it redirects the user โ€” typically to a login page or a 'Not Authorized' screen. Here is a reusable ProtectedRoute component built for React Router v6:

typescript
// src/components/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

interface ProtectedRouteProps {
  requiredPermission?: string;
}

export function ProtectedRoute({ requiredPermission }: ProtectedRouteProps) {
  const { user, hasPermission } = useAuth();

  // Not logged in at all โ†’ redirect to login
  if (!user) {
    return <Navigate to="/login" replace />;
  }

  // Logged in but missing the required permission โ†’ show 403
  if (requiredPermission && !hasPermission(requiredPermission)) {
    return <Navigate to="/403" replace />;
  }

  // All checks passed โ†’ render the child route
  return <Outlet />;
}

Now wire it into your router. Notice how each sensitive route declares exactly which permission is required โ€” making authorization requirements explicit and easy to audit:

typescript
// src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Dashboard } from './pages/Dashboard';
import { AdminPanel } from './pages/AdminPanel';
import { UserManagement } from './pages/UserManagement';
import { Login } from './pages/Login';
import { Forbidden } from './pages/Forbidden';

export default function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<Login />} />
          <Route path="/403" element={<Forbidden />} />

          {/* Any logged-in user can see the dashboard */}
          <Route element={<ProtectedRoute />}>
            <Route path="/dashboard" element={<Dashboard />} />
          </Route>

          {/* Only users with 'manage:users' permission */}
          <Route element={<ProtectedRoute requiredPermission="manage:users" />}>
            <Route path="/admin/users" element={<UserManagement />} />
          </Route>

          {/* Only users with 'manage:settings' permission */}
          <Route element={<ProtectedRoute requiredPermission="manage:settings" />}>
            <Route path="/admin" element={<AdminPanel />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}

If a viewer tries to navigate directly to /admin/users โ€” whether by typing the URL or clicking a link โ€” they are immediately redirected to /403. The page never renders. This is route-level protection.

Conditional UI: Permission-Based Rendering and Buttons

Route protection handles page-level access. But within a page, different users should see different controls. An admin sees a 'Delete' button; an editor sees only 'Edit'; a viewer sees neither. This is conditional UI rendering based on permissions.

First, build a reusable Can component that wraps any UI element and only renders it when the user has the required permission:

typescript
// src/components/Can.tsx
import { ReactNode } from 'react';
import { useAuth } from '../context/AuthContext';

interface CanProps {
  permission: string;
  children: ReactNode;
  fallback?: ReactNode; // Optional: render something else if not allowed
}

export function Can({ permission, children, fallback = null }: CanProps) {
  const { hasPermission } = useAuth();
  return hasPermission(permission) ? <>{children}</> : <>{fallback}</>;
}

Now use it anywhere in your UI. The component reads cleanly โ€” you can tell at a glance what permission each action requires:

typescript
// src/pages/UserManagement.tsx
import { Can } from '../components/Can';

interface User {
  id: string;
  name: string;
  email: string;
}

export function UserManagement() {
  const users: User[] = [
    { id: '1', name: 'Alice', email: 'alice@example.com' },
    { id: '2', name: 'Bob', email: 'bob@example.com' },
  ];

  const handleDelete = (id: string) => {
    // This MUST also be protected on the backend!
    fetch(`/api/users/${id}`, { method: 'DELETE' });
  };

  const handleEdit = (id: string) => {
    fetch(`/api/users/${id}`, { method: 'PATCH' });
  };

  return (
    <div>
      <h1>User Management</h1>
      {users.map((u) => (
        <div key={u.id} style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
          <span>{u.name} โ€” {u.email}</span>

          {/* Editors and above can edit */}
          <Can permission="edit:post">
            <button onClick={() => handleEdit(u.id)}>Edit</button>
          </Can>

          {/* Only admins can delete */}
          <Can
            permission="delete:users"
            fallback={<span style={{ color: 'gray' }}>No delete access</span>}
          >
            <button onClick={() => handleDelete(u.id)} style={{ color: 'red' }}>
              Delete
            </button>
          </Can>
        </div>
      ))}
    </div>
  );
}

The fallback prop is optional but useful โ€” instead of silently hiding a button, you can show a disabled state or a tooltip explaining why the action is unavailable. This improves UX without compromising security.

Real-World Scenario: Admin vs User Dashboard

Let's put it all together in a realistic scenario. Imagine a SaaS app with two roles: admin (permissions: read:users, manage:users, manage:settings) and user (permissions: read:users). Both land on the same Dashboard page, but they see different things.

typescript
// src/pages/Dashboard.tsx
import { useAuth } from '../context/AuthContext';
import { Can } from '../components/Can';
import { Link } from 'react-router-dom';

export function Dashboard() {
  const { user, logout } = useAuth();

  return (
    <div style={{ padding: '24px' }}>
      <h1>Welcome, {user?.email}</h1>
      <p>Your role: <strong>{user?.role}</strong></p>

      <nav style={{ display: 'flex', gap: '16px', margin: '24px 0' }}>
        {/* Everyone sees this */}
        <Link to="/dashboard">Home</Link>

        {/* Only users who can manage other users */}
        <Can permission="manage:users">
          <Link to="/admin/users">User Management</Link>
        </Can>

        {/* Only users who can manage settings */}
        <Can permission="manage:settings">
          <Link to="/admin">Admin Panel</Link>
        </Can>
      </nav>

      {/* Admin-only stats block */}
      <Can permission="manage:settings">
        <div style={{ background: '#f0f4ff', padding: '16px', borderRadius: '8px' }}>
          <h2>Admin Stats</h2>
          <p>Total users: 1,240</p>
          <p>Active subscriptions: 980</p>
          <p>Revenue this month: $48,200</p>
        </div>
      </Can>

      {/* Regular user sees a simpler view */}
      <Can permission="read:users">
        <div style={{ background: '#f9f9f9', padding: '16px', borderRadius: '8px' }}>
          <h2>Your Activity</h2>
          <p>Posts published: 12</p>
          <p>Last login: Today</p>
        </div>
      </Can>

      <button onClick={logout} style={{ marginTop: '24px' }}>Log out</button>
    </div>
  );
}

An admin sees the navigation links to User Management and Admin Panel, plus the revenue stats block. A regular user sees only the Home link and their own activity. Same component, same route โ€” different experience based on permissions. Clean, maintainable, and easy to extend.

Backend Enforcement: The Non-Negotiable Part

Your React RBAC is a UX layer. Your backend RBAC is the actual security. Every API endpoint that performs a sensitive action must independently verify the caller's permissions โ€” it cannot trust anything sent from the client except the signed JWT token.

Here is a Node.js / Express middleware example that verifies the JWT and checks permissions before allowing a request through:

js
// server/middleware/authorize.js
const jwt = require('jsonwebtoken');

// Verify JWT and attach decoded user to req.user
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];
  try {
    // Verify signature using the SECRET โ€” never trust unverified client data
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Check that the authenticated user has a required permission
function authorize(permission) {
  return (req, res, next) => {
    const userPermissions = req.user?.permissions ?? [];
    if (!userPermissions.includes(permission)) {
      return res.status(403).json({ error: 'Forbidden: insufficient permissions' });
    }
    next();
  };
}

module.exports = { authenticate, authorize };
js
// server/routes/users.js
const express = require('express');
const { authenticate, authorize } = require('../middleware/authorize');
const router = express.Router();

// GET /api/users โ€” any authenticated user
router.get('/', authenticate, (req, res) => {
  res.json({ users: [] }); // fetch from DB
});

// DELETE /api/users/:id โ€” only users with 'delete:users' permission
router.delete(
  '/:id',
  authenticate,
  authorize('delete:users'),
  (req, res) => {
    // Safe to proceed โ€” backend has verified the permission
    res.json({ message: `User ${req.params.id} deleted` });
  }
);

module.exports = router;

Notice that the backend reads permissions from the verified JWT payload (req.user.permissions) โ€” it does NOT accept a role or permissions field from the request body or query string. The JWT signature guarantees the payload was issued by your server and has not been tampered with.

Common Security Mistakes to Avoid

RBAC is easy to get conceptually right and subtly wrong in practice. Here are the most common mistakes developers make โ€” and how to fix them.

MistakeWhy It's DangerousFix
Hiding buttons without protecting APIsAnyone can call the API directly with a valid tokenAdd authorization middleware to every sensitive endpoint
Trusting roles/permissions from the clientA user can modify request bodies or headersAlways derive permissions from the server-verified JWT payload
Storing authorization logic only in frontendFrontend code is public โ€” anyone can read itKeep business rules and data filters on the backend
Checking role names instead of permissionsBrittle โ€” breaks when roles are renamed or restructuredCheck specific permissions like 'delete:users' instead of role === 'admin'
Embedding sensitive data in JWT without expiryStolen tokens grant permanent accessSet short JWT expiry (e.g., 15 min) and use refresh tokens
Not handling token expiry in ReactUsers stay 'logged in' with an expired token, causing confusing errorsDecode exp from JWT and log out automatically when it expires

Frequently Asked Questions

  • Q: Should I store the JWT in localStorage or a cookie? โ€” Both have trade-offs. localStorage is vulnerable to XSS attacks. HttpOnly cookies are immune to XSS but require CSRF protection. For most apps, HttpOnly cookies with SameSite=Strict are the safer default.
  • Q: Can a user have multiple roles? โ€” Yes. Your JWT can include a roles array instead of a single role string. Merge all permissions from all roles before storing them in the token or checking them in middleware.
  • Q: What if permissions change while the user is logged in? โ€” The JWT reflects permissions at login time. For real-time permission changes, use short-lived tokens (15โ€“60 min) with refresh tokens, or invalidate sessions server-side using a token blocklist.
  • Q: Is it okay to decode the JWT on the frontend without verifying the signature? โ€” For UI purposes only, yes โ€” you are just reading the payload to drive rendering. But the backend MUST verify the signature on every request. Never skip server-side verification.
  • Q: What is the difference between authentication and authorization? โ€” Authentication answers 'Who are you?' (login, identity). Authorization answers 'What are you allowed to do?' (permissions, RBAC). RBAC is purely an authorization concern.
  • Q: Should I use a library like CASL or react-query for RBAC? โ€” Libraries like CASL provide a powerful, expressive permission system and are worth considering for complex apps. For simpler apps, the pattern shown in this guide (a hasPermission function + a Can component) is often sufficient and easier to own.

Next Steps and Further Reading

You now have a complete, working RBAC system for React: a JWT-powered AuthContext, a reusable ProtectedRoute, a declarative Can component, and a backend that actually enforces what the frontend promises. Here is what to explore next as your app grows:

  1. Attribute-Based Access Control (ABAC): Extend RBAC with conditions like 'a user can edit a post only if they are the author'. CASL handles this elegantly.
  2. Refresh token rotation: Implement short-lived access tokens with rotating refresh tokens to minimize the impact of token theft.
  3. Audit logging: Log every sensitive action (who did what, when, from which IP) on the backend for compliance and debugging.
  4. Permission management UI: Build an admin screen that lets you assign roles to users dynamically, stored in your database rather than hardcoded in the JWT.
  5. Row-level security: In your database (e.g., PostgreSQL RLS), enforce that users can only query rows they own โ€” a final, deep layer of defense.

The golden rule of RBAC in React: build the frontend permission layer for great UX, but always build the backend permission layer for actual security. One without the other is either a bad experience or a vulnerability โ€” you need both.