Build CoreOrdered learning track

Route Metadata Permissions

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 039

Route metadata permissions for React Router apps: handle-based permission metadata, route policy contracts, inherited policy, loader/action integration, menu derivation, static analysis, testing, and secure defaults.

9 min read1702 words
PrevNext
Lesson 39130 lesson track25–71 Build Core
#react#react-router#authentication#authorization+10 more

Part 039 — Route Metadata Permissions

Route metadata is how a React app makes route-level intent explicit.

A route is not just a URL.

A route is a product surface.

A route usually implies:

  • what layout is used,
  • what data is needed,
  • what navigation item should appear,
  • what permission is required,
  • what tenant or resource context must exist,
  • what recovery should happen when access is denied,
  • what audit event should be emitted,
  • what action surfaces should be visible.

If those facts are scattered across components, loaders, sidebars, breadcrumbs, and action buttons, the auth model becomes implicit.

Implicit auth is hard to review.

Implicit auth is hard to test.

Implicit auth eventually drifts.

Route metadata gives the team a single place to declare route intent.

But route metadata has one hard rule:

Route metadata can describe required access.
It does not enforce access by itself.

The enforcement still belongs in the loader/action/API/server policy boundary.

Route metadata is a contract.

The contract is useful only when the runtime and tests consume it consistently.


1. The problem route metadata solves

In many React apps, authorization begins with code like this:

<Route
  path="/admin/users"
  element={
    <ProtectedRoute roles={["admin"]}>
      <UsersPage />
    </ProtectedRoute>
  }
/>

Then the sidebar has separate logic:

{user.role === "admin" && <NavLink to="/admin/users">Users</NavLink>}

Then the breadcrumb has another copy:

if (pathname.startsWith("/admin")) return "Admin";

Then the loader has a partial check:

if (!session) throw redirect("/login");

Then the API enforces something else.

The system now has multiple access models:

Route tree model        role == admin
Sidebar model           role == admin
Breadcrumb model        pathname heuristic
Loader model            authenticated only
API model               maybe permission-based

This is not a security architecture.

This is accidental duplication.

The first job of route metadata is to make the route contract explicit:

/admin/users
  requires authenticated session
  requires tenant context
  requires permission user.read in current tenant
  appears in admin navigation only when visible
  uses AdminLayout
  denies with forbidden page, not redirect-to-login

Once declared, the contract can be consumed by:

  • route loaders,
  • route actions,
  • middleware,
  • navigation menus,
  • breadcrumbs,
  • page title generation,
  • command palette,
  • analytics/audit,
  • tests,
  • static checks,
  • docs.

2. React Router route handle as metadata carrier

React Router exposes route metadata through handle and useMatches().

A matched route hierarchy can carry arbitrary route-specific information.

That makes it natural to attach auth metadata to each route.

Example route object style:

import { createBrowserRouter } from "react-router";
import { requireAuthLoader } from "./auth/require-auth-loader";
import { requirePermissionLoader } from "./auth/require-permission-loader";

export const router = createBrowserRouter([
  {
    id: "root",
    path: "/",
    Component: RootLayout,
    loader: rootSessionLoader,
    handle: {
      auth: {
        public: true,
      },
    },
    children: [
      {
        id: "dashboard",
        path: "dashboard",
        Component: DashboardPage,
        loader: requireAuthLoader(dashboardLoader),
        handle: {
          nav: {
            label: "Dashboard",
            section: "main",
          },
          auth: {
            requiresSession: true,
          },
        },
      },
      {
        id: "admin.users",
        path: "admin/users",
        Component: UsersPage,
        loader: requirePermissionLoader({
          action: "user.read",
          resource: "tenant",
        }, usersLoader),
        handle: {
          nav: {
            label: "Users",
            section: "admin",
          },
          auth: {
            requiresSession: true,
            requiresTenant: true,
            permissions: [
              {
                action: "user.read",
                resource: "tenant",
              },
            ],
          },
        },
      },
    ],
  },
]);

The important part is not the property name.

The important part is the invariant:

The route declares what it needs.
The loader/action enforces what it needs.
The UI derives exposure from the same declaration.

3. Metadata is a policy projection, not the whole policy

Route metadata should not become your entire authorization engine.

A route cannot fully decide object-level access.

This route can declare a coarse requirement:

Route: /cases/:caseId
Requirement: case.read

But the real authorization decision may depend on:

  • the case id,
  • the current tenant,
  • the user's assignment,
  • case state,
  • confidentiality level,
  • escalation status,
  • delegation rules,
  • regulatory hold,
  • time-bound access,
  • separation-of-duty constraints.

So route metadata should answer:

What kind of access does this route require?

The policy engine/server should answer:

Can this subject perform this action on this exact resource under this context?

Do not ask route metadata to do what only the resource boundary can do.


4. A useful route auth metadata shape

A production route auth schema should be explicit enough to be reviewed.

Avoid boolean-only metadata:

// Too weak
handle: {
  auth: true,
}

Prefer structured metadata:

export type AuthRouteMetadata =
  | {
      public: true;
      guestOnly?: boolean;
    }
  | {
      requiresSession: true;
      requiresTenant?: boolean;
      requiresMfa?: boolean;
      requiredAssuranceLevel?: "aal1" | "aal2" | "aal3";
      permissions?: PermissionRequirement[];
      denyMode?: "not-found" | "forbidden" | "login" | "step-up";
      audit?: {
        viewEvent?: string;
        denialEvent?: string;
      };
    };

export type PermissionRequirement = {
  action: PermissionAction;
  resource: ResourceKind;
  scope?: "global" | "tenant" | "resource";
  mode?: "all" | "any";
  reason?: string;
};

export type PermissionAction =
  | "dashboard.read"
  | "user.read"
  | "user.invite"
  | "case.read"
  | "case.update"
  | "case.escalate"
  | "report.export";

export type ResourceKind =
  | "global"
  | "tenant"
  | "user"
  | "case"
  | "report";

This shape allows several important distinctions:

public route                  anyone can access
requiresSession               authenticated user only
requiresTenant                authenticated user must be inside tenant context
requiresMfa                   session is valid but not strong enough yet
permissions                   route needs domain-level capability
requiredAssuranceLevel        sensitive route needs stronger auth event
scope                         global vs tenant vs resource-scoped action
mode                          any/all semantics for composite requirements
denyMode                      what UX recovery should be used

The schema should be boring.

Boring metadata is reviewable metadata.


5. Use typed route IDs

Route permission metadata becomes much more useful when routes have stable IDs.

Avoid relying only on URL strings.

URLs change.

Route IDs express product meaning.

export const routeIds = {
  root: "root",
  dashboard: "dashboard",
  adminUsers: "admin.users",
  caseDetail: "case.detail",
  caseEscalation: "case.escalation",
  reportsExport: "reports.export",
} as const;

export type AppRouteId = (typeof routeIds)[keyof typeof routeIds];

Then define route metadata records:

export type RouteSecurityRecord = {
  routeId: AppRouteId;
  auth: AuthRouteMetadata;
};

This makes tests easier:

const protectedRoutes = collectRoutes(router.routes)
  .filter((route) => route.handle?.auth?.requiresSession);

it.each(protectedRoutes)("%s has loader enforcement", (route) => {
  expect(route.loader).toBeDefined();
});

The point is not only type safety.

The point is architectural accountability.

Every route can be enumerated.

Every route can be reviewed.

Every route can be checked.


6. Inheritance: powerful but dangerous

Nested routes invite inherited metadata.

Example:

/app                  requires session
/app/admin            requires admin area permission
/app/admin/users      requires user.read
/app/admin/roles      requires role.read

A naive implementation may merge metadata automatically:

const effectiveAuth = mergeAuthMetadata(matches.map((m) => m.handle?.auth));

This is useful.

It is also dangerous.

Inheritance bugs often produce silent privilege widening.

Bad merge logic:

// Dangerous: child can accidentally erase parent requirement.
const merged = { ...parentAuth, ...childAuth };

If child route has:

handle: {
  auth: {
    permissions: [{ action: "user.read", resource: "tenant" }],
  },
}

and parent had:

handle: {
  auth: {
    requiresSession: true,
    requiresTenant: true,
  },
}

simple object overwrite may remove required session/tenant constraints.

Use monotonic merge semantics.

export function mergeAuthMetadata(items: Array<AuthRouteMetadata | undefined>) {
  const result = {
    public: false,
    requiresSession: false,
    requiresTenant: false,
    requiresMfa: false,
    permissions: [] as PermissionRequirement[],
    denyMode: undefined as AuthenticatedDenyMode | undefined,
  };

  for (const item of items) {
    if (!item) continue;

    if ("public" in item && item.public) {
      // Public should usually only be allowed at leaf or explicitly isolated.
      // Do not let public erase parent authenticated requirements implicitly.
      continue;
    }

    result.requiresSession = result.requiresSession || item.requiresSession === true;
    result.requiresTenant = result.requiresTenant || item.requiresTenant === true;
    result.requiresMfa = result.requiresMfa || item.requiresMfa === true;

    if (item.permissions) {
      result.permissions.push(...item.permissions);
    }

    result.denyMode = item.denyMode ?? result.denyMode;
  }

  return result;
}

Better rule:

Parent constraints can be strengthened by children.
Children cannot silently weaken parent constraints.

If a child needs to weaken access, require an explicit escape hatch:

handle: {
  auth: {
    public: true,
    overridesParentAuth: "explicit-public-child-route",
  },
}

That escape hatch should be rare and test-covered.


7. Route metadata and loader enforcement must agree

The most common failure is metadata drift.

Example:

handle: {
  auth: {
    requiresSession: true,
    permissions: [{ action: "report.export", resource: "tenant" }],
  },
},
loader: async () => {
  // BUG: only checks session, not permission
}

Or the opposite:

handle: {
  auth: {
    requiresSession: true,
  },
},
loader: async () => {
  // Actually requires report.export, but metadata forgot it.
}

Both are bad.

The first may expose unauthorized data.

The second may hide route/menu incorrectly and confuse users.

Use a wrapper that receives the same metadata:

export function secureLoader<T>(
  auth: AuthRouteMetadata,
  loader: (args: LoaderFunctionArgs & { auth: AuthContext }) => Promise<T>,
) {
  return async (args: LoaderFunctionArgs) => {
    const session = await readSession(args.request);
    const authContext = await enforceRouteAuth(auth, session, args);
    return loader({ ...args, auth: authContext });
  };
}

Then route declaration becomes harder to drift:

const usersAuth = {
  requiresSession: true,
  requiresTenant: true,
  permissions: [
    { action: "user.read", resource: "tenant", scope: "tenant" },
  ],
  denyMode: "forbidden",
} satisfies AuthRouteMetadata;

export const usersRoute = {
  id: "admin.users",
  path: "admin/users",
  loader: secureLoader(usersAuth, async ({ auth }) => {
    return api.users.list({ tenantId: auth.tenantId });
  }),
  Component: UsersPage,
  handle: {
    auth: usersAuth,
    nav: {
      label: "Users",
      section: "admin",
    },
  },
};

One object.

Two consumers.

Less drift.


8. Route metadata should not contain secrets

Never put secrets, tokens, or hidden policy internals in route metadata.

Route metadata may be bundled into client JavaScript.

This is fine:

handle: {
  auth: {
    permissions: [{ action: "report.export", resource: "tenant" }],
  },
}

This is not fine:

handle: {
  auth: {
    internalPolicyId: "policy-prod-tenant-super-secret",
    serviceToken: "...",
    hiddenAdminBypass: true,
  },
}

Treat route metadata as public design information.

The attacker can inspect it.

That should not matter.

Security must not depend on metadata being hidden.


9. Permission metadata should use domain capabilities, not raw roles

This is a common beginner mistake:

handle: {
  auth: {
    roles: ["admin"],
  },
}

It works until it does not.

Roles are bundles.

Routes need capabilities.

Prefer:

handle: {
  auth: {
    permissions: [
      { action: "user.read", resource: "tenant" },
    ],
  },
}

Then the backend can decide which roles, grants, relationships, or attributes provide that capability.

Route asks: user.read on tenant
Policy decides: owner/admin/support-delegate/group-membership/temporary grant

This keeps React away from role semantics.

React should not know all role mapping rules.

React only needs enough information to:

  • request data safely,
  • show/hide UI surfaces,
  • explain denial,
  • avoid obviously impossible actions.

10. Resource resolution in dynamic routes

Dynamic routes need special care.

Example:

/cases/:caseId

The route metadata can declare:

handle: {
  auth: {
    requiresSession: true,
    requiresTenant: true,
    permissions: [
      { action: "case.read", resource: "case", scope: "resource" },
    ],
  },
}

But it cannot authorize until caseId is resolved.

The loader must resolve the resource and call server-side policy:

export const caseDetailAuth = {
  requiresSession: true,
  requiresTenant: true,
  permissions: [
    { action: "case.read", resource: "case", scope: "resource" },
  ],
  denyMode: "not-found",
} satisfies AuthRouteMetadata;

export const loader = secureLoader(caseDetailAuth, async ({ params, auth }) => {
  const caseId = requireParam(params, "caseId");

  const response = await api.cases.getCase({
    tenantId: auth.tenantId,
    caseId,
  });

  // API must enforce object-level authorization.
  // The frontend only handles the result.
  return response;
});

Do not fetch the case first in a generic API without object-level authorization and then decide in React whether to show it.

That leaks data.


11. denyMode: forbidden vs not-found

Some routes should return 403 Forbidden.

Some routes should hide existence with 404 Not Found semantics.

This is not purely UX.

It is part of the information disclosure model.

Examples:

/admin/users              403 is fine; the admin area exists.
/cases/secret-case-id     404-like denial may be safer.
/invitations/token        invalid/expired generic response is safer.

Route metadata can declare the intended denial mode:

handle: {
  auth: {
    requiresSession: true,
    permissions: [
      { action: "case.read", resource: "case", scope: "resource" },
    ],
    denyMode: "not-found",
  },
}

The loader/API must still be the actual enforcement point.

The route metadata helps the error boundary choose safe UX:

function AuthErrorBoundary() {
  const error = useRouteError();

  if (isProblem(error, "RESOURCE_NOT_FOUND_OR_FORBIDDEN")) {
    return <NotFoundLikePage />;
  }

  if (isProblem(error, "FORBIDDEN")) {
    return <ForbiddenPage />;
  }

  throw error;
}

12. Navigation from metadata

A route tree can derive navigation.

But navigation exposure is not authorization enforcement.

It is UX filtering.

export type NavMetadata = {
  label: string;
  section: "main" | "admin" | "settings";
  icon?: string;
  order?: number;
  hidden?: boolean;
};

Route:

handle: {
  nav: {
    label: "Users",
    section: "admin",
    order: 20,
  },
  auth: {
    requiresSession: true,
    requiresTenant: true,
    permissions: [
      { action: "user.read", resource: "tenant" },
    ],
  },
}

Navigation derivation:

export function buildVisibleNavItems(
  routes: AppRoute[],
  permissionSnapshot: PermissionSnapshot,
): NavItem[] {
  return flattenRoutes(routes)
    .filter((route) => route.handle?.nav && !route.handle.nav.hidden)
    .filter((route) => isRouteVisible(route.handle.auth, permissionSnapshot))
    .map((route) => ({
      to: route.fullPath,
      label: route.handle.nav.label,
      section: route.handle.nav.section,
      order: route.handle.nav.order ?? 0,
    }))
    .sort((a, b) => a.order - b.order);
}

Visibility function:

export function isRouteVisible(
  auth: AuthRouteMetadata | undefined,
  snapshot: PermissionSnapshot,
): boolean {
  if (!auth) return false;

  if ("public" in auth && auth.public) return true;

  if (auth.requiresSession && !snapshot.authenticated) return false;
  if (auth.requiresTenant && !snapshot.tenantId) return false;
  if (auth.requiresMfa && !snapshot.mfaSatisfied) return false;

  for (const requirement of auth.permissions ?? []) {
    if (!snapshot.can(requirement.action, requirement.resource)) {
      return false;
    }
  }

  return true;
}

This is acceptable because it is only exposure control.

The loader/API still enforces.


13. Breadcrumbs and page titles

Breadcrumbs often leak information.

Bad:

<Breadcrumbs>
  <Link to="/cases">Cases</Link>
  <Link to={`/cases/${case.id}`}>{case.title}</Link>
</Breadcrumbs>

If case.title loads before authorization is established, the breadcrumb leaks sensitive resource identity.

Route metadata should separate static route label from resource label.

handle: {
  breadcrumb: {
    label: "Case Details",
    dynamicLabelFromLoaderData: true,
  },
  auth: caseDetailAuth,
}

Then render dynamic label only after authorized loader data exists:

function Breadcrumbs() {
  const matches = useMatches();

  return (
    <nav aria-label="Breadcrumb">
      {matches
        .filter((match) => match.handle?.breadcrumb)
        .map((match) => {
          const crumb = match.handle.breadcrumb;
          const label =
            crumb.dynamicLabelFromLoaderData && isAuthorizedData(match.data)
              ? match.data.breadcrumbLabel
              : crumb.label;

          return <span key={match.id}>{label}</span>;
        })}
    </nav>
  );
}

Same principle applies to document title:

Before authorization: Case Details
After authorized data: Case #REG-2026-0042
Never before authorization: Confidential Investigation: Insider Fraud

Command palettes are easy to forget.

They create alternative navigation paths.

If a route is hidden from the sidebar but still searchable in a command palette, the UI exposure model is inconsistent.

Route metadata should feed all navigation surfaces:

Sidebar
Top nav
Breadcrumbs
Command palette
Quick switcher
Search result shortcuts
Keyboard shortcuts
Mobile bottom tab

Use the same route visibility function for all of them.

const visibleRoutes = getVisibleRoutes({
  routes,
  permissionSnapshot,
  surface: "command-palette",
});

But never treat this as final security.

Users can still type the URL.

The loader/action/API must deny.


15. Route metadata and feature flags

Feature flags and permissions are not the same.

A route can require both:

handle: {
  flags: {
    required: ["new-report-export"],
  },
  auth: {
    requiresSession: true,
    permissions: [
      { action: "report.export", resource: "tenant" },
    ],
  },
}

Interpretation:

Feature flag: is this product capability released/enabled?
Permission: is this subject allowed to use it?

Do not replace permission checks with flags.

Do not replace release flags with permissions.

They answer different questions.


16. Route metadata and tenant context

Multi-tenant apps need route metadata to declare tenant assumptions.

Examples:

/login                         no tenant required
/orgs                          authenticated, no active tenant required
/:tenantSlug/dashboard          authenticated, tenant required
/:tenantSlug/admin/users        authenticated, tenant required, user.read
/support/tenants/:tenantId      support context, special audit requirements

Metadata:

handle: {
  auth: {
    requiresSession: true,
    requiresTenant: true,
    permissions: [
      { action: "dashboard.read", resource: "tenant" },
    ],
  },
  tenant: {
    source: "path-param",
    param: "tenantSlug",
    switchBehavior: "clear-route-caches",
  },
}

Tenant context must be resolved before permission checks.

Route metadata declares tenant dependency.

Server policy enforces tenant membership.


17. Route metadata for step-up requirements

Sensitive routes may require stronger authentication.

Example:

handle: {
  auth: {
    requiresSession: true,
    requiresTenant: true,
    requiredAssuranceLevel: "aal2",
    permissions: [
      { action: "billing.manage", resource: "tenant" },
    ],
    denyMode: "step-up",
  },
}

The loader should enforce both permission and assurance freshness:

async function enforceRouteAuth(auth: AuthRouteMetadata, session: Session) {
  if (auth.requiredAssuranceLevel && !sessionMeetsAal(session, auth.requiredAssuranceLevel)) {
    throw stepUpRequired({
      requiredAssuranceLevel: auth.requiredAssuranceLevel,
      returnTo: currentSafePath(),
    });
  }

  // then permission checks
}

Do not hide step-up routes completely if users have the underlying permission.

Often the correct UX is:

Visible action -> click -> step-up challenge -> return -> continue

Not:

Invisible forever because current session is not fresh enough

Visibility and executability are related but not identical.


18. Static analysis for route security

Once metadata is explicit, you can test your route tree.

Useful checks:

Every non-public route has auth metadata.
Every route with auth metadata has loader or inherited middleware enforcement.
Every route with action has action-level authorization metadata or explicit public mutation marker.
Every nav route has auth metadata.
Every route with resource path param has resource-scoped permission metadata.
Every admin route has tenant requirement unless explicitly global.
Every route with denyMode=not-found has error boundary capable of rendering not-found-like denial.
No route declares raw roles except migration-approved routes.
No route uses unsafe external return URL handling.

Example test:

describe("route security metadata", () => {
  const routes = flattenRoutes(appRoutes);

  it("requires explicit auth metadata on every route", () => {
    for (const route of routes) {
      expect(route.handle?.auth).toBeDefined();
    }
  });

  it("requires permission metadata for admin routes", () => {
    for (const route of routes.filter((r) => r.id?.startsWith("admin."))) {
      const auth = route.handle?.auth;
      expect(auth && "permissions" in auth && auth.permissions?.length).toBeTruthy();
    }
  });

  it("does not use raw roles in route metadata", () => {
    for (const route of routes) {
      expect(JSON.stringify(route.handle?.auth ?? {})).not.toContain('"roles"');
    }
  });
});

This is where route metadata pays off.

You can test architecture.

Not just components.


19. Generate a route access matrix

Route metadata can produce documentation automatically.

Example matrix:

Route IDPathSessionTenantPermissionDeny Mode
dashboard/dashboardyesoptionaldashboard.readforbidden
admin.users/admin/usersyesyesuser.readforbidden
case.detail/cases/:caseIdyesyescase.readnot-found
reports.export/reports/exportyesyesreport.exportforbidden

This matrix helps architecture review.

It also helps product/security alignment:

Which routes are public?
Which routes require tenant context?
Which routes expose admin surfaces?
Which routes hide resource existence?
Which routes require MFA/step-up?
Which routes are missing ownership checks?

A strong auth system is inspectable.


20. Metadata-driven prefetching constraints

Modern routers and frameworks may prefetch route data or bundles.

Do not prefetch sensitive data for routes the user is not allowed to access.

Bundle prefetching is usually not a secret problem.

Data prefetching is.

Route metadata can guide prefetch decisions:

function canPrefetchRoute(route: AppRoute, snapshot: PermissionSnapshot): boolean {
  const auth = route.handle?.auth;

  if (!auth) return false;

  if ("public" in auth && auth.public) return true;

  if (!isRouteVisible(auth, snapshot)) return false;

  // Even when visible, avoid prefetching sensitive resource routes without explicit user intent.
  if (auth.permissions?.some((p) => p.scope === "resource")) {
    return false;
  }

  return true;
}

Rule of thumb:

Prefetch public/static surfaces freely.
Prefetch tenant dashboards carefully.
Do not prefetch sensitive object data just because a link exists.

21. Metadata and cache invalidation

Permission metadata helps determine which caches should be invalidated after auth changes.

Events:

login
logout
tenant switch
role changed
membership removed
MFA satisfied
session revoked
impersonation start/end
permission refresh

Cache invalidation strategy:

export function invalidateAuthSensitiveQueries(
  queryClient: QueryClient,
  event: AuthEvent,
) {
  switch (event.type) {
    case "logout":
    case "session-revoked":
      queryClient.clear();
      return;

    case "tenant-switched":
      queryClient.invalidateQueries({ queryKey: ["tenant"] });
      queryClient.removeQueries({ queryKey: ["case"] });
      return;

    case "permissions-changed":
      queryClient.invalidateQueries({ queryKey: ["permissions"] });
      queryClient.invalidateQueries({ queryKey: ["nav"] });
      return;
  }
}

Route metadata does not perform invalidation itself.

It helps identify the surfaces affected by authorization context changes.


22. Common anti-patterns

Anti-pattern: route metadata as enforcement

if (route.handle.auth.permissions.includes("admin")) {
  renderPage();
}

This is UI logic.

It is not enforcement.

Anti-pattern: raw role checks in routes

handle: {
  auth: { roles: ["admin"] }
}

This couples React to identity-provider or backend role packaging.

Anti-pattern: public by omission

// No auth metadata means public.

Bad default.

Use explicit public metadata:

handle: {
  auth: { public: true }
}

Anti-pattern: hiding routes but not denying loaders

Sidebar hides admin route.
User types /admin/users.
Loader returns data.

Classic broken access control.

Anti-pattern: inconsistent permission naming

user.read
users:view
READ_USER
canViewUsers
admin.users.read

Normalize permission vocabulary.

Routes become reviewable only when permission names are stable.


23. A practical route metadata module structure

One maintainable structure:

src/
  auth/
    route-auth.ts
    permission-types.ts
    enforce-route-auth.ts
    route-security-tests.ts
  routes/
    app-routes.tsx
    route-handles.ts
    admin/
      users.route.tsx
      roles.route.tsx
    cases/
      case-detail.route.tsx

permission-types.ts:

export const permissions = {
  dashboardRead: "dashboard.read",
  userRead: "user.read",
  userInvite: "user.invite",
  caseRead: "case.read",
  caseUpdate: "case.update",
  caseEscalate: "case.escalate",
  reportExport: "report.export",
} as const;

export type Permission = (typeof permissions)[keyof typeof permissions];

route-auth.ts:

export function requirePermission(
  action: Permission,
  resource: ResourceKind,
  options: Partial<PermissionRequirement> = {},
): PermissionRequirement {
  return {
    action,
    resource,
    scope: options.scope ?? "tenant",
    mode: options.mode ?? "all",
    reason: options.reason,
  };
}

Route file:

const auth = {
  requiresSession: true,
  requiresTenant: true,
  permissions: [requirePermission(permissions.userRead, "tenant")],
  denyMode: "forbidden",
} satisfies AuthRouteMetadata;

export const route = {
  id: "admin.users",
  path: "admin/users",
  loader: secureLoader(auth, loader),
  Component: UsersPage,
  handle: {
    auth,
    nav: {
      label: "Users",
      section: "admin",
      order: 20,
    },
  },
};

The auth object is now explicit, typed, reusable, testable.


24. Review checklist

Use this checklist in PR review:

[ ] Does every route have explicit auth metadata?
[ ] Is public access explicitly marked, not implied by omission?
[ ] Is route metadata capability-based, not raw-role-based?
[ ] Does the loader enforce the same or stronger requirement?
[ ] Does the action enforce mutation-specific authorization?
[ ] Are dynamic resource routes enforced by object-level API policy?
[ ] Does denyMode match information disclosure risk?
[ ] Are tenant assumptions declared and enforced?
[ ] Are step-up requirements explicit for sensitive routes?
[ ] Are sidebar, breadcrumb, command palette, and shortcuts derived from the same route metadata?
[ ] Are caches invalidated when auth context changes?
[ ] Are tests checking metadata/enforcement drift?

25. Mental model

Route metadata is a map.

A map is not a wall.

The wall is server-side authorization.

But without a map, engineers build doors inconsistently.

Good route metadata gives the frontend a shared language:

This route is public.
This route needs a session.
This route needs tenant context.
This route needs this capability.
This route hides resource existence on denial.
This route needs step-up.
This route appears in this navigation surface.

Then the system can align:

Router        knows when to redirect or throw typed denial.
Layout        knows what to expose.
Loader        knows what to enforce before data.
Action        knows what to enforce before mutation.
API           remains final authority.
Tests         know what invariants to verify.
Audit         knows what route intent was attempted.

That is the point.

Not prettier route config.

Architectural alignment.


References

  • React Router documentation: handle and useMatches() for route metadata access.
  • React Router documentation: route modules, loaders, actions, error boundaries, and route objects.
  • OWASP Authorization Cheat Sheet: validate permission on every request, deny-by-default, least privilege.
  • OWASP Top 10 Broken Access Control: access should be granted only for particular capabilities, roles, or users.
  • MDN HTTP status documentation for 401, 403, and cache-control behavior.
Lesson Recap

You just completed lesson 39 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.