Askr documentation
Guides

Build an Authenticated Full-Stack App

Build an Authenticated Full-Stack App: a worked guide from route registry through to a production build.

Example

Resolve the session on the server, enforce permissions at both route and handler boundaries, and expose only the principal data needed for rendering.

import { allOf, createAuth, requirePermission, requireUser } from '@askrjs/auth';

const auth = createAuth({ sessions: sessionStore, principals: principalStore });
const canManageProjects = allOf(requireUser(), requirePermission('projects:write'));

router.post('/projects', createProject, { auth: canManageProjects });

Goal and architecture

This shape combines `@askrjs/server` for HTTP with `@askrjs/auth` for identity resolution and `@askrjs/askr`'s router for auth-aware client routes. `createAuth({ sessions, principals, jwt })` builds one `AuthResolver` your server middleware calls per request to produce an `AuthContext`; the same shared requirement factories — `requireUser()`, `requireRole()`, `requirePermission()` — gate both server routes and client route policies, so you're not maintaining two separate permission systems.

Implementation

On the server, attach `auth: requirePermission('reports:read')` to individual routes and read `ctx.auth.principal` in the handler; `createJwtValidator()` or a JWKS-backed validator plugs into `AuthOptions.jwt` if you're validating bearer tokens rather than cookies. On the client, `route(path, Component, { policies: [...] })` — plural, an array of requirement functions, not a single `policy` field — with `allow()`/`deny()`/`redirect()` decisions from your policy functions keeps navigation from ever rendering a page the user shouldn't see, and `currentAuth()` gives components access to the resolved identity for the route currently rendering.

Failure states

A denied server route should return a real HTTP status through `deny(status)` — `unauthorized()` or `forbidden()` — not a 200 with an empty body; clients checking `result.ok` in `@askrjs/fetch` depend on that. On the client, an `AccessDenyDecision` should route through `redirect('/login')` for anonymous users versus a dedicated forbidden page for authenticated users lacking permission — conflating the two is the most common auth UX bug. Session expiry mid-session needs its own path: a stale `AuthContext` should trigger a re-resolve or redirect, not a raw 401 rendered as page content.

Verification

Test all three identity states explicitly: anonymous, authenticated without the required role, and authenticated with it — each should reach a different, correct outcome on both server routes and client-rendered pages. Confirm a token that fails `JwtValidator` verification resolves to an unauthenticated context and is then rejected by the route requirement with a clear 401 or redirect, without leaking validation details. Finally verify that server-side auth decisions and client-side route policies agree; a route hidden in the nav but reachable directly by URL because only the client checked permissions is a real vulnerability, not just a UX gap.