Protected Routes and Permissions
Use protected routes and permissions with the current published Askr packages.
@askrjs/askr0.0.59
How to use protected routes and permissions
Require authentication before rendering protected routes and enforce the same permission again on the server operation that owns the data.
- Import the published @askrjs/askr entrypoint.
- Keep protected routes and permissions configuration next to the component, route, or server composition root that owns it.
- Handle pending, unavailable, cancellation, and failure states, then verify the production path.
import { requirePermission, requireUser } from '@askrjs/auth';
const canReadBilling = [requireUser(), requirePermission('billing:read')];
route('/billing', BillingPage, { auth: { requirements: canReadBilling } });
router.get('/api/billing', ...canReadBilling, getBilling);Goal and architecture
Route protection in Askr is split into two layers: `@askrjs/auth` resolves who is making the request into an `AuthContext` (principal, session, tenant, scopes), and the router's `RouteAuthOptions` decides what that context is allowed to see. A route's `auth.resolve` function runs `createAuth(...).resolve(request)` and returns the context; the route or group's `auth.check` (an `AuthRequirement`) inspects that context and returns an `AccessDecision`. 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 to `registerRoutes` (browser) or `createServerApp` (server), so every matched route gets the same `AuthContext`. Inside a route's guard, return `allow()` to proceed, `deny('forbidden' | 'unauthorized' | ...)` for a hard stop, 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. 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.
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.