Askr documentation
Guides

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.