Askr
Guides

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.

  1. Import the published @askrjs/askr entrypoint.
  2. Keep protected routes and permissions configuration next to the component, route, or server composition root that owns it.
  3. 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.