Skip to content

Router: Authorization

Route protection is one server-side guard, not a middleware matcher you wire up per route. The guard runs in the adapter’s core() request handler before any HTML flushes (pre-stream), so a protected page never leaks its content — there’s no client-side gate to bypass, no useEffect redirect, no flash of protected UI. A page opts in by declaring restricted; the guard resolves the current session, evaluates the declaration, and either renders the page or returns the matching status-code page.

1. Per-page restricted — the page declares it itself. In a .phaze page a bare restricted in the ---page fence compiles to true; value forms pass through. A .tsx page writes export const restricted:

---page
restricted // bare → true (any signed-in session)
restricted: 'admin' // requires the 'admin' role
---
src/pages/billing/page.tsx
export const restricted = ['admin', 'billing'] // holds any one of these roles

2. +name/ protected folders — a folder prefixed with + flags its whole subtree restricted (any session) with no per-page export. The + strips from the URL (+admin//admin) and protection inherits to every descendant:

src/pages/
└── +admin/ ← whole subtree restricted; URL is /admin (not /+admin)
├── index.phaze → /admin (inherits — any session)
└── super/page.phaze → /admin/super (inherits; can narrow further — below)

A per-page restricted wins over the folder flag (the guard reads module.restricted ?? route.restricted), so +admin/super/page.phaze can declare restricted: 'admin' to narrow that one page to a role while the rest of +admin/ stays any-session. Nesting two + folders is a build error — protection is a single subtree flag, not a stack.

ValueMeaningSigned-in but unqualified →
true (or bare restricted)any authenticated session
falseexplicit public opt-out — wins even inside a +name/ subtree (the one way to carve a public page out of a protected folder)
'admin'requires that role403
['admin', 'editor']holds any one of these roles403
(session) => booleanpredicate — all-of, step-up, any custom rule; receives the full session403
{ roles, notFound: true }options — a roles list + notFound to return 404 instead of 403 (hide the route’s existence)403 / 404

false is a true opt-out, not just “skipped”: the guard short-circuits on it, and module.restricted ?? route.restricted keeps false (nullish-coalescing only falls through on undefined), so a restricted = false page is public even under a +protected/ ancestor.

The guard resolves the session through a consumer-provided resolver — cloudflare({ session }) or a src/session.ts convention — shaped like NextAuth’s getSessionAndUser (KV-backed); the guard needs only session.user.roles for RBAC. With no resolver registered, every request to a restricted page is treated as unauthenticated (the 401 flow) — protection fails closed.

The guard’s verdict maps to a status-code page, rendered in place with the real status:

VerdictOutcome
no session401 — signed /login redirect, or 401.phaze in place under isomorphicAuth (below)
signed in, role/rule fails403 in place (403.phaze) — or 404 (notFound)
passesthe page renders normally

403 / 404 always render in place — same URL, real status, no Location header. Re-authenticating never clears a 403: it’s an authorization gap, not a sign-in one.

401 — signed redirect, or in-place (isomorphicAuth)

Section titled “401 — signed redirect, or in-place (isomorphicAuth)”

The unauthenticated (401) case is the one configurable outcome:

cloudflare({ isomorphicAuth })401 behaviour
false (default)Ed25519-signed 302 redirect to /login (the loginPath), shaped /login?to=<return-path>&exp=<ms>&sig=<base64>. The guard signs to|exp with PHAZE_SIGN_KEY; the phaze-router verifies that signature before following, so a forged/tampered to (MITM, poisoned cache, an open-redirect attempt) is rejected. exp bounds replay of a stale link. Bookmarkable login flow.
truerenders 401.{tsx,phaze} in place at the protected URL — no redirect, no Location, no return-URL. A successful sign-in re-gates the same page to its content with no navigation.
// vite.config.ts — default is the signed /login redirect; flip to render 401 in place
cloudflare({ pages: 'src/pages', isomorphicAuth: true, loginPath: '/login' })

The signed redirect’s validity window is authRedirectTtl (seconds, default minutes(5)) — how long a signed /login?to=… link stays honorable before the router rejects it as stale:

import { minutes } from '@madenowhere/phaze/time'
cloudflare({ pages: 'src/pages', authRedirectTtl: minutes(2) })

The signing scheme, the non-bypassable pre-stream guarantee, and the contrast with Next’s middleware-matcher CVEs: Security → Secure by design.