route-based-permissions
$npx skills add blunotech-dev/agents --skill route-based-permissionsImplement route-level permission enforcement using guard composition and declarative route manifests, supporting nested and wildcard routes. Use when centralizing permissions, chaining guards, or enforcing access across dynamic or hierarchical routes.
| name | description | category |
|---|---|---|
| route-based-permissions | Implement route-level permission enforcement using guard composition and declarative route manifests, supporting nested and wildcard routes. Use when centralizing permissions, chaining guards, or enforcing access across dynamic or hierarchical routes. | Fullstack |
Route-Based Permissions
Covers structural patterns for route permission enforcement: centralized route manifests, guard composition, and nested route inheritance. Assumes basic role/permission primitives exist (can(), requireRole) — focuses on how to wire them across a route tree without scattering checks everywhere.
Discovery
Before writing anything, answer:
- How many guards per route? Single role check, or multiple independent conditions (auth + role + feature flag + subscription tier)?
- Route declaration style: File-based (Next.js App Router, Remix) or config-based (React Router
createBrowserRouter, Express router)? - Nested inheritance: Should child routes inherit parent permissions, or must each route declare its own?
- Dynamic segments: Do permissions depend on the resource at
/:id(ownership checks), or only on role? - Backend: Express/Fastify router, or Next.js API/middleware?
Core Patterns
1. Route Manifest — Declare Permissions Centrally
The trap: scattering requireRole('admin') inline across 40 route definitions. When permissions change, you hunt through every file.
Fix: a single manifest maps routes to their permission requirements. Guards read from it.
// routeManifest.ts — single source of route permission declarations
import type { Permission } from '@your-org/permissions';
interface RouteRule {
permission?: Permission;
requireAuth?: boolean;
redirectTo?: string;
}
export const ROUTE_MANIFEST: Record<string, RouteRule> = {
'/admin': { permission: 'user:manage', redirectTo: '/403' },
'/admin/billing': { permission: 'billing:manage', redirectTo: '/403' },
'/posts/new': { permission: 'post:write', redirectTo: '/403' },
'/posts/:id/edit': { permission: 'post:write', redirectTo: '/403' },
'/dashboard': { requireAuth: true },
};
// Matcher: find the most specific rule for a given path
export function getRuleForPath(pathname: string): RouteRule | null {
// Exact match first
if (ROUTE_MANIFEST[pathname]) return ROUTE_MANIFEST[pathname];
// Parameterized match — replace segments with :param pattern
const normalized = pathname.replace(/\/[a-f0-9-]{8,}|\d+/g, '/:id');
return ROUTE_MANIFEST[normalized] ?? null;
}
Non-obvious: the manifest only works for static and parameterized routes. Resource-level checks (does this user own /posts/123?) still belong in the handler — the manifest can't express ownership.
2. Guard Composition — Chaining Independent Checks
When a route needs multiple independent conditions, don't nest them — compose them.
// Backend: compose guards as an array, run left-to-right
type Guard = (req: Request, res: Response, next: NextFunction) => void;
function composeGuards(...guards: Guard[]): Guard {
return (req, res, next) => {
const run = (index: number) => {
if (index >= guards.length) return next(); // all passed
guards[index](req, res, () => run(index + 1));
};
run(0);
};
}
// Guards stay single-responsibility
const requireAuth = guardFromRule({ requireAuth: true });
const requireEditor = requireRole('editor', 'admin');
const requireFeatureX = (req, res, next) =>
req.user.features?.includes('feature_x') ? next() : res.status(403).json({ error: 'Feature not enabled' });
// Route declaration stays clean
router.post('/posts',
composeGuards(requireAuth, requireEditor, requireFeatureX),
createPost
);
// Frontend: compose route guards as wrappers
function composeGuards(...Guards: React.ComponentType<{ children: React.ReactNode }>[]) {
return function ComposedGuard({ children }: { children: React.ReactNode }) {
return Guards.reduceRight(
(acc, Guard) => <Guard>{acc}</Guard>,
children as React.ReactElement
);
};
}
const AdminEditorGuard = composeGuards(AuthGuard, RoleGuard('editor'), FeatureGuard('feature_x'));
<Route element={<AdminEditorGuard><Outlet /></AdminEditorGuard>}>
<Route path="/posts/new" element={<NewPost />} />
</Route>
3. Nested Route Permission Inheritance
The trap: each child route re-declares the same parent permission. Easy to miss one.
// React Router v6 — parent guard wraps all children via Outlet
// Children get the permission "for free" without declaring it
<Route element={<ProtectedRoute permission="user:manage" />}>
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/users" element={<UserList />} />
<Route path="/admin/users/:id" element={<UserDetail />} />
{/* All three inherit the parent permission check */}
</Route>
// Stricter child — add a second guard for a subset of routes
<Route element={<ProtectedRoute permission="user:manage" />}>
<Route path="/admin/users" element={<UserList />} />
<Route element={<ProtectedRoute permission="billing:manage" />}>
<Route path="/admin/billing" element={<Billing />} />
</Route>
</Route>
// Express — apply parent guard to the router, not individual routes
const adminRouter = express.Router();
adminRouter.use(requireRole('admin')); // applies to all routes below
adminRouter.get('/users', listUsers);
adminRouter.get('/users/:id', getUser);
adminRouter.delete('/users/:id', deleteUser);
// Stricter sub-router
const billingRouter = express.Router();
billingRouter.use(requireRole('admin'), requireFeatureBilling);
billingRouter.get('/', getBilling);
adminRouter.use('/billing', billingRouter);
app.use('/admin', adminRouter);
Non-obvious: in Express, router.use() order matters. A guard added after a route definition won't protect that route.
4. Next.js Middleware — Wildcard and Prefix Matching
Next.js middleware runs on the Edge and must match routes via config matcher. Most teams under-specify this.
// middleware.ts
import { NextResponse } from 'next/server';
import { getRuleForPath } from './routeManifest';
import { can } from '@your-org/permissions';
import { getSessionFromCookie } from './lib/session';
export async function middleware(req: NextRequest) {
const rule = getRuleForPath(req.nextUrl.pathname);
if (!rule) return NextResponse.next(); // no rule = public route
const session = await getSessionFromCookie(req);
if (rule.requireAuth && !session) {
return NextResponse.redirect(new URL(`/login?next=${req.nextUrl.pathname}`, req.url));
}
if (rule.permission && !can(session?.role, rule.permission)) {
return NextResponse.redirect(new URL(rule.redirectTo ?? '/403', req.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
// Non-obvious: exclude static assets and API routes that handle their own auth
'/((?!_next/static|_next/image|favicon.ico|api/).*)',
],
};
Non-obvious: the matcher negative lookahead (?!api/) means middleware skips /api/* entirely. API routes must enforce their own permissions — middleware won't cover them.
5. Dynamic Segments — What the Manifest Can't Express
Route manifests handle role checks. Ownership checks (/posts/:id — does this user own post 123?) cannot be expressed in a manifest and must stay in the handler.
// Pattern: split enforcement explicitly in the handler
async function editPost(req: Request, res: Response) {
// Layer 1: role check (could be middleware, but shown inline for clarity)
if (!can(req.user.role, 'post:write')) {
return res.status(403).json({ error: 'Forbidden' });
}
// Layer 2: ownership check — requires the resource, can't be in middleware
const post = await Post.findById(req.params.id);
if (!post) return res.status(404).json({ error: 'Not found' });
const isOwner = post.authorId === req.user.id;
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
// Return 404, not 403 — don't confirm the resource exists to unauthorized users
return res.status(404).json({ error: 'Not found' });
}
// Proceed
}
Keep the manifest for what it's good at (role/auth gates on known paths) and don't try to stretch it to express ownership or resource-level conditions.
Output
Produce:
routeManifest.ts— centralized route-to-permission map with path matchercomposeGuards.ts— backend and frontend composition utilities- Integration snippets showing nested route inheritance (React Router + Express router)
Flag clearly in comments:
- What the manifest can and cannot express (role vs ownership)
- Guard execution order in composition
- Next.js
matcherexclusions and what they leave unprotected