Protected Routes and Permissions
Protected Routes and Permissions: a worked guide from route registry through to a production build.
Example
Require authentication before rendering protected routes and enforce the same permission again on the server operation that owns the data.
import { allOf, requirePermission, requireUser } from '@askrjs/auth';
const canReadBilling = allOf(requireUser(), requirePermission('billing:read'));
route('/billing', BillingPage, { auth: canReadBilling });
router.get('/api/billing', getBilling, { auth: canReadBilling });Goal and architecture
Route protection in Askr is split into two layers: `@askrjs/auth` resolves who is making the request into an `AuthContext` (`authenticated`, `principal`, `session`, `tenant`, `scopes`), and the router's `RouteAuthOptions` decides what that context is allowed to see. `RouteAuthOptions.resolve` runs `createAuth(...).resolve(request)` and returns the context — there's no separate `auth.check` field alongside it; the per-route requirement is a sibling option, just named `auth` on the route or group itself (an `AuthRequirement` that inspects the context and returns an `AccessDecision`), not nested under `.check`. Compose requirements with `requireUser()`, `requireRole('admin')`, `requirePermission('billing:write')`, `requireScope(...)`, `allOf(...)`, and `anyOf(...)` rather than writing ad-hoc boolean checks scattered through page components. Because the check runs before a route renders, denied users never see a flash of protected UI.
Implementation
Build the resolver once with `createAuth({ sessions, principals, jwtCookie, tenant })` and pass it as the `auth` option in the second argument to `createRouteRegistry` (browser) or to `createServerApp` (server), so every matched route gets the same `AuthContext`. Inside a route's guard, return `allow()` to proceed, `deny(401 | 403 | 404)` for a hard stop — the argument is a numeric HTTP status, not a string like `'forbidden'` — or `redirect(to, init)` to bounce anonymous users to a login page while preserving their intended destination. `unauthorized()`, `forbidden()`, and `notFound()` are shorthand `deny()` variants for the common HTTP-flavored cases (401, 403, 404 respectively). On the server, `registerAuthRoutes(api, { issuer, cookie, principalSchema, register, authenticate, allowAttempt })` wires up ready-made register/login endpoints that issue a signed cookie via a `TokenIssuer`, so you don't hand-roll session cookie handling — the 429 rate-limit path from a throttled `allowAttempt` goes straight to a `tooManyRequests()` response rather than through `AuthRouteError`, which exists for your own register/authenticate callbacks to throw instead.
Failure states
An unauthenticated visitor hitting a `requireUser()` route should get a `redirect()` decision to your sign-in route, not a bare 401 page — use `safeRedirect(fallback)` to sanitize the `next` query param so you're not building an open redirect. A signed-in user without the right role or permission should get `forbidden()`, which is a distinct case from `unauthorized()` and should render differently (e.g., 'you don't have access' versus 'please sign in'). Rate limit auth endpoints through the `allowAttempt(context, operation, normalizedEmail)` hook in `registerAuthRoutes` so repeated failed logins return `429` via `AuthRouteError` instead of quietly retrying forever.
Verification
Test each `AuthRequirement` directly by calling it with hand-built `AuthContext` fixtures (`{ authenticated, principal, session, tenant, scopes }`) and asserting on the returned `AccessDecision` — no HTTP server needed. For end-to-end coverage, hit protected routes with and without a valid session cookie and confirm you get `allow`, `redirect`, or the expected `deny` status, including the `already_authenticated` reason for routes like login that should reject already-signed-in users. Rotate a JWT's `kid` in your JWKS fixture and confirm `createJwtValidator` rejects it with `unknown_key` rather than silently accepting an unverifiable token.