🛡️ Declarative Protection
Protect a page by declaring requiredRoles in the layout props—no imperative check inside the
page body.
The Auth layout role guards feature protects entire pages based on user roles. The layout performs a hierarchical role check and, when the visitor lacks the required role, denies in place: it renders an “insufficient permissions” alert and withholds the page content.
A layout cannot redirect (see Why the layout cannot redirect).
Pages that need an unauthorized visitor sent elsewhere pair the layout prop with a one-line
frontmatter guard, redirectUnauthorized(), that runs before any data fetch.
Auth layout role guards let you declare required roles directly in the layout props. When the check fails, the guarded content is never rendered and an alert is shown in its place.
🛡️ Declarative Protection
Protect a page by declaring requiredRoles in the layout props—no imperative check inside the
page body.
🚫 Deny In Place
An under-privileged visitor gets an “insufficient permissions” alert and the page slot is withheld—island props carrying protected data are never serialized.
↪️ Frontmatter Redirects
Need a redirect instead of an in-place alert? Add redirectUnauthorized(Astro, requiredRoles)
to the page’s own frontmatter.
📝 Error Integration
A denial always speaks—the layout shows the insufficient_permissions message automatically,
and the redirect helper stamps ?error=insufficient_permissions on its target URL.
redirectUnauthorized() in frontmatter When:/dashboard)302 redirect) rather than a 200 with an alertRoleGuard component or canViewContent() When:Every protected page in this project combines two guards, and they do different jobs:
redirectUnauthorized(Astro, requiredRoles) runs at the
very top of the page’s own frontmatter, before any data fetch. It returns a redirect Response
for an unauthorized user, or undefined to continue.requiredRoles on <Auth> (or <ServiceDayLayout>) makes
the layout deny in place if the frontmatter guard is ever removed. It is a backstop, not the
primary redirect mechanism.---import Auth from '#layouts/Auth.astro'import { redirectUnauthorized } from '#utils/role-guard'import type { AnyRole } from '#utils/role-types'
const requiredRoles: AnyRole[] = ['admin', 'super_admin']
// Layer 1 — the redirect. Runs before any fetch; sends an under-privileged// visitor to /dashboard?error=insufficient_permissions.const denied = await redirectUnauthorized(Astro, requiredRoles)if (denied) return denied---
{/* Layer 2 — requiredRoles makes the layout deny in place as a backstop */}<Auth pageTitle="Admin Test Page" requiredRoles={requiredRoles}> <section> <h1>Admin Dashboard</h1> <p>Welcome to the admin-only area.</p> </section></Auth>What happens:
redirectUnauthorized returns undefined, the page renders./dashboard?error=insufficient_permissions. (If that line were removed, the layout would instead
deny in place: show the alert and withhold the slot.)An Astro page’s frontmatter runs first and can call Astro.redirect() cleanly, before any
response bytes leave the server. An Astro layout’s frontmatter runs later — while the page’s
HTML is already streaming. Calling Astro.redirect() at that point throws ResponseSentError and
the visitor gets a crash page instead of a redirect.
So the Auth layout does the only correct thing for a mid-stream guard: it denies in place.
---// src/layouts/Auth.astro (excerpt)const denied = !!requiredRoles?.length && !(await canViewContent(Astro.locals, requiredRoles))
// A denial always speaks, even when the page passed no errorMessage.const alertMessage = denied ? ERROR_MESSAGES.insufficient_permissions : finalErrorMessage---
{alertMessage && <section>{/* ...insufficient-permissions alert... */}</section>}
<MainSection showBreadcrumb={false}> {/* Denied: skip the slot so the guarded page never renders or serializes. */} {!denied && <slot />}</MainSection>| Prop | Type | Description | Default |
|---|---|---|---|
| pageTitle | string | Page title for SEO and navigation | "Authentication" |
| pageDescription | string | Meta description for SEO | undefined |
| pageImageUrl | string | Header image URL | undefined |
| hideHeader | boolean | Hide the navigation header | true |
| Prop | Type | Description | Default |
|---|---|---|---|
| requiredRoles | AnyRole[] | Roles allowed to view the page (hierarchical: floor or higher). When the check fails, the layout denies in place—alert shown, slot withheld. | undefined (no check) |
| Prop | Type | Description | Default |
|---|---|---|---|
| errorMessage | string | Custom error message to display in alert | undefined |
| errorType | 'error' | 'warning' | 'info' | Alert visual style | 'error' |
redirectUnauthorized() HelperExported from #utils/role-guard, this is the page-frontmatter guard that produces the redirect.
redirectUnauthorized( astro: { locals; url; redirect }, // pass the page's `Astro` global requiredRoles: AnyRole[], redirectTo = '/dashboard'): Promise<Response | undefined>requiredRoles → returns undefined (the page continues).Response to redirectTo with
?error=insufficient_permissions appended.---import { redirectUnauthorized } from '#utils/role-guard'import { SERVICE_DAY_STATION_ROLES } from '#constants/service-day-access'
const denied = await redirectUnauthorized(Astro, SERVICE_DAY_STATION_ROLES)if (denied) return denied
// ...safe to fetch protected data below this line...---Run it before any data fetch. The whole point is that a denied user never triggers the frontmatter queries (guest directories, order lists) that follow.
When the layout denies in place, it shows the insufficient_permissions message automatically—
no per-page error handling required. The alert renders below the navigation area, in place of the
withheld page content.
The layout maps these error codes (used both by the deny-in-place alert and by the
?error= query parameter that redirectUnauthorized() stamps on its target):
| Error Code | Message |
|---|---|
insufficient_permissions | ”You do not have permission to access that page.” |
session_expired | ”Your session has expired. Please sign in again.” |
invalid_request | ”The request was invalid. Please try again.” |
not_found | ”The requested resource was not found.” |
When a page loads with /dashboard?error=insufficient_permissions (the URL
redirectUnauthorized() produces), the destination layout reads the param and shows the matching
message.
Override the automatic message with the errorMessage prop—useful for surfacing non-auth errors
(a failed save, for example):
---import Auth from '#layouts/Auth.astro'
const saveError = Astro.url.searchParams.get('save_error')---
<Auth pageTitle="Edit Profile" errorMessage={saveError ? 'Failed to save profile. Please try again.' : undefined} errorType="error"> <!-- Page content --></Auth>Control the visual style of the alert with the errorType prop:
<Auth pageTitle="Admin Panel" errorMessage="Access denied. Admin privileges required." errorType="error"> <!-- Content --></Auth>Visual: Red background (#fee), red border (#c33), dark red text (#800)
Use for: Critical errors, access denied, authentication failures
<Auth pageTitle="Settings" errorMessage="Some features are temporarily unavailable." errorType="warning"> <!-- Content --></Auth>Visual: Yellow background (#fffbea), orange border (#f59e0b), brown text (#92400e)
Use for: Non-critical issues, deprecation notices, temporary limitations
<Auth pageTitle="Dashboard" errorMessage="Your profile has been updated successfully." errorType="info"> <!-- Content --></Auth>Visual: Blue background (#eff6ff), blue border (#3b82f6), dark blue text (#1e40af)
Use for: Success messages, informational alerts, status updates
When the visitor is authorized, the alert message resolves in this order:
errorMessage prop (highest priority)?error=... param)A denial overrides all of the above with the insufficient_permissions message.
---import Auth from '#layouts/Auth.astro'import { redirectUnauthorized } from '#utils/role-guard'import type { AnyRole } from '#utils/role-types'
const requiredRoles: AnyRole[] = ['admin', 'super_admin']
const denied = await redirectUnauthorized(Astro, requiredRoles)if (denied) return denied---
<Auth pageTitle="Admin Panel" pageDescription="Administrative management interface" requiredRoles={requiredRoles}> <section> <h1>Admin Panel</h1> <p>Manage users, settings, and system configuration.</p> </section></Auth>Station pages source their role tiers from a single shared constant so the six pages cannot drift
apart, and they render through ServiceDayLayout—a thin pass-through that forwards requiredRoles
to Auth.
---import { SERVICE_DAY_STATION_ROLES } from '#constants/service-day-access'import { redirectUnauthorized } from '#utils/role-guard'import ServiceDayLayout from '#layouts/ServiceDayLayout.astro'
const requiredRoles = SERVICE_DAY_STATION_ROLES
// Authorize BEFORE any fetch: the frontmatter below reads guest data, and a// denied user must never trigger it. The layout keeps `requiredRoles` as// defense in depth.const denied = await redirectUnauthorized(Astro, requiredRoles)if (denied) return denied
// ...fetch the station's queue/guest data here...---
<ServiceDayLayout pageTitle="Barber Station" requiredRoles={requiredRoles}> <!-- Station content --></ServiceDayLayout>The access tiers live in src/constants/service-day-access.ts:
SERVICE_DAY_STATION_ROLES — floor volunteer (barber, showers, welcome desk, fulfillment).SERVICE_DAY_ORDER_DESK_ROLES — floor team_admin (the order desk lists every user’s orders).Because canViewContent matches hierarchically, listing the floor alone would behave identically;
the lists name every tier explicitly so the call site reads clearly and the access tests can pin
both readings.
Code:
---import Auth from '#layouts/Auth.astro'import { redirectUnauthorized } from '#utils/role-guard'import type { AnyRole } from '#utils/role-types'
const requiredRoles: AnyRole[] = ['admin']
const denied = await redirectUnauthorized(Astro, requiredRoles)if (denied) return denied---
<Auth pageTitle="Admin Page" requiredRoles={requiredRoles}> <h1>Admin Content</h1></Auth>Pros:
Best For: entire-page protection; pages that fetch protected data in frontmatter.
Code:
---import RoleGuard from '#components/astro/RoleGuard.astro'---
<h1>Page Content</h1>
<RoleGuard allowedRoles={['admin']}> <div>Admin-only section</div></RoleGuard>Pros:
Best For: section-level protection; conditional content on an otherwise-public page.
The layout guard is a single await canViewContent(Astro.locals, requiredRoles) call. Role lookups
are backed by an in-memory cache with a 1-minute TTL (see #utils/role-guard), so repeated checks
for the same user within that window skip the Supabase query entirely. Clerk org roles resolve
straight from Astro.locals with no database round-trip at all.
Always type role arrays as AnyRole[]:
---import type { AnyRole } from '#utils/role-types'
// ✅ CORRECT - Type-safeconst requiredRoles: AnyRole[] = ['admin', 'super_admin']
// ❌ INCORRECT - No type checkingconst requiredRoles = ['admin', 'super_admin']---Prefer the shared constants (SERVICE_DAY_STATION_ROLES, SERVICE_DAY_ORDER_DESK_ROLES) over
inline literals for any tier used by more than one page.
Call redirectUnauthorized() at the very top of frontmatter, before any data fetch:
---// ✅ CORRECT - deny before touching protected dataconst denied = await redirectUnauthorized(Astro, requiredRoles)if (denied) return denied
const guests = await getAllClients() // never runs for a denied user------// ❌ INCORRECT - fetch runs before the guard; a denied user still triggers itconst guests = await getAllClients()const denied = await redirectUnauthorized(Astro, requiredRoles)if (denied) return denied---Pass requiredRoles to the layout even when the frontmatter guard already redirects. It costs
nothing and guarantees the page still denies in place if the guard line is ever removed in a
refactor.
Leave redirectTo at its /dashboard default, or point it at another un-gated authenticated
route. Redirecting a denied user to a page that itself requires the same missing role creates an
infinite redirect loop.
Test with the correct role:
Test without the required role (frontmatter guard present):
/dashboard?error=insufficient_permissionsTest deny-in-place (layout prop only, no frontmatter guard):
redirectUnauthorized(), navigate without the required role200 showing the “insufficient permissions” alertThe shared access tiers are pinned by tests/utils/service-day-access.test.ts, which asserts which
roles each SERVICE_DAY_*_ROLES list admits and rejects—so “a volunteer can reach the stations,
a member cannot” is enforced against the exact constants the pages import.
Problem: You are redirected (or see the alert) despite holding the required role.
Solutions:
config/roles.config.tsclearRoleCache(userId)Problem: An unauthorized visitor gets a 200 with the “insufficient permissions” alert, but you
expected a redirect.
Cause: The page relies on the layout’s requiredRoles alone. The layout can only deny in
place—it cannot redirect.
Solution: Add the frontmatter guard to the page:
---const denied = await redirectUnauthorized(Astro, requiredRoles)if (denied) return denied---Problem: An unauthorized visitor bounces between two URLs.
Cause: redirectUnauthorized()’s redirectTo points at another route gated by the same role.
Solution: Redirect to /dashboard (the default) or another route the user can reach.
Problem: Server logs show guest/order queries running for users who are ultimately denied.
Cause: The data fetch is placed above the guard in frontmatter.
Solution: Move redirectUnauthorized() (and its if (denied) return denied) to the top of the
frontmatter, before any fetch.
The dashboard navigation shows and hides links based on role using the same canViewContent()
primitive the layout guard uses:
---import { canViewContent } from '#utils/role-guard'
const canViewEvents = await canViewContent(Astro.locals, ['admin', 'super_admin'])const canViewUsers = await canViewContent(Astro.locals, ['team_admin', 'admin', 'super_admin'])---
<nav> {canViewEvents && <a href="/dashboard/events">Events</a>} {canViewUsers && <a href="/dashboard/users">Users</a>} <a href="/dashboard/orders">Orders</a> <!-- All authenticated users --></nav>Layout guards and redirectUnauthorized() protect pages. API endpoints are different: they must
follow the project’s endpoint conventions — auth-first, returning the JSON error envelope with
the correct status code. Do not use requireRole() here: it throws, and an uncaught throw
surfaces as a 500, not the 401/403 contract. Use the non-throwing canViewContent() and return
responses explicitly.
import type { APIRoute } from 'astro'import { canViewContent } from '#utils/role-guard'
export const POST: APIRoute = async ({ locals, request }) => { // 1. Auth-first — 401 JSON, never throw if (!locals.userId) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }) }
// 2. Authorization — 403 JSON for an authenticated but under-privileged caller if (!(await canViewContent(locals, ['admin', 'super_admin']))) { return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }) }
// 3. CSRF + input validation + business logic (see below), then respond const data = await request.json() // ...
return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' }, })}requireRole() patternssrc/layouts/Auth.astro — the denied computation and the {!denied && <slot />}
withholding.redirectUnauthorized() in src/utils/role-guard.ts.src/constants/service-day-access.ts.src/pages/service-day/*.astro and src/pages/dashboard/admin-test.astro
all run const denied = await redirectUnauthorized(Astro, requiredRoles); if (denied) return denied
before rendering.tests/utils/service-day-access.test.ts.src/types/generated-roles.ts.