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.