How would you protect routes based on user roles (e.g., Admin vs User)
Layered: server enforces (authoritative — API endpoints check role on every request); router-level guard component on the client checks role and redirects/denies; conditionally render UI elements (hide admin buttons for non-admins). Never trust the client — the client guard is UX only. Bake role into the auth token; refresh it when roles change.
Role-based route protection is defense in depth. The client guard is for UX; the server is the source of truth. Build both, and don't confuse one for the other.
1. Server is the source of truth (always)
Every API endpoint checks the user's role:
app.get("/admin/users", requireRole("admin"), handler);If the client guard is wrong (or bypassed), the server refuses. Client-side authorization is never sufficient — anyone can inspect or modify the JS.
2. Auth carries role information
The auth token (JWT, session) includes the user's role(s) in claims:
{ "sub": "u_42", "roles": ["admin"], "exp": ... }- Verify on the server every request (don't trust the client to assert role).
- Refresh the token when roles change (e.g., user promoted) — stale tokens grant stale permissions.
3. Client-side router guard
For UX — redirect non-admins away from admin routes before showing a 403:
function ProtectedRoute({ role, children }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
if (role && !user.roles.includes(role)) return <Navigate to="/403" replace />;
return children;
}Used at the route definition:
<Route path="/admin" element={
<ProtectedRoute role="admin"><AdminLayout/></ProtectedRoute>
}>
<Route path="users" element={<UserList/>} />
</Route>4. Conditional UI
Hide buttons/menus the user can't use:
{can("admin") && <Button onClick={openAdminPanel}>Admin</Button>}This is UX, not security — but it prevents user confusion ("why is this button here if I can't click it?"). A small can(role) or <Authorized roles={['admin']}> helper centralizes the check.
5. Granular permissions (beyond role)
Roles are coarse. For real apps, you usually need permissions (capabilities): canEditPost, canDeleteUser. Roles map to permission sets:
{ "permissions": ["post.edit", "user.read"] }- The token carries permissions (or the server returns them at login).
- Guards check permissions, not raw roles.
- Easier to evolve: adding a new feature means adding a permission, not a role.
6. Server-side rendering
For SSR (Next.js, etc.), check auth in getServerSideProps / middleware before rendering:
export async function getServerSideProps({ req }) {
const user = await getUser(req);
if (!user) return { redirect: { destination: "/login", permanent: false } };
if (!user.roles.includes("admin")) return { redirect: { destination: "/403", permanent: false } };
return { props: { user } };
}This prevents the brief flash of unauthorized content client-side guards can show.
7. 401 vs 403
- 401 Unauthorized — not authenticated (no/expired token). Redirect to login, preserve destination.
- 403 Forbidden — authenticated but insufficient permission. Show a 403 page; don't redirect to login (the user IS logged in).
Don't conflate them — different UX.
8. Role changes during a session
If an admin demotes a user, their token may still claim admin until expiry. Mitigations:
- Short token TTLs (5–15 min) with refresh tokens.
- A revocation list / "session version" check the server validates per request.
- Push a re-login when role changes.
9. Common smells
- Client-only check with no server enforcement (admin endpoints reachable via curl).
- Hardcoding roles in many components instead of a centralized
can(permission)helper. - Forgetting SSR — the page renders with admin UI then hides it on client guard.
- Caching admin data in service worker visible to former admins.
Interview framing
"Defense in depth. The server is authoritative — every endpoint checks the user's role/permissions from the auth token, which is verified server-side every request. The client adds UX guards: a <ProtectedRoute role='admin'> component that redirects non-admins to a 403 page before they see the protected route, and a can(permission) helper to hide UI elements the user can't use. For SSR, do the auth check in getServerSideProps to avoid flashing protected content. Distinguish 401 (not logged in → login) from 403 (no access → 403 page). And the architectural point: roles are coarse; in real apps you want permissions (capabilities) and roles as permission sets. Always remember the client guard is UX, never security."
Follow-up questions
- •Why is server-side enforcement essential?
- •Difference between 401 and 403?
- •Roles vs permissions — when do you need both?
- •How do you handle a role change during a session?
Common mistakes
- •Client-only role check, no server enforcement.
- •Hardcoded role strings everywhere.
- •Treating 401 and 403 the same.
- •Flashing protected UI before the client guard kicks in (no SSR check).
- •Stale tokens granting stale permissions.
Performance considerations
- •Negligible. Centralize the permission check; don't make the guard call the API on every render.
Edge cases
- •Role demoted mid-session.
- •User has multiple roles.
- •Permission scoped to a specific resource (can edit *this* doc, not all docs).
- •Impersonation flows for support.
Real-world examples
- •Admin dashboards (Shopify Admin, Stripe Dashboard).
- •Enterprise SaaS with workspace + member roles.