Skip to content

Auth Layout Role Guards

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.

  • ✅ Protecting entire pages based on roles
  • ✅ You want a declarative, self-documenting backstop on the layout
  • ✅ Rendering an in-place denial notice is acceptable UX for the page
  • ✅ Building admin dashboards or role-specific sections

Add redirectUnauthorized() in frontmatter When:

Section titled “Add redirectUnauthorized() in frontmatter When:”
  • ✅ An unauthorized visitor should be sent to another route (e.g. /dashboard)
  • ✅ The page fetches protected data in its frontmatter (guest names, orders) that a denied user must never trigger
  • ✅ You want status-code semantics (a 302 redirect) rather than a 200 with an alert

Use the RoleGuard component or canViewContent() When:

Section titled “Use the RoleGuard component or canViewContent() When:”
  • ✅ Protecting specific sections within an otherwise-public page
  • ✅ Conditionally rendering role-based content

Every protected page in this project combines two guards, and they do different jobs:

  1. Frontmatter guard — the redirect. 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.
  2. Layout prop — defense in depth. 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.
src/pages/dashboard/admin-test.astro
---
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:

  • User has the required role → redirectUnauthorized returns undefined, the page renders.
  • User lacks the required role → the frontmatter guard returns a redirect to /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>
PropTypeDescriptionDefault
pageTitlestringPage title for SEO and navigation"Authentication"
pageDescriptionstringMeta description for SEOundefined
pageImageUrlstringHeader image URLundefined
hideHeaderbooleanHide the navigation headertrue
PropTypeDescriptionDefault
requiredRolesAnyRole[]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)
PropTypeDescriptionDefault
errorMessagestringCustom error message to display in alertundefined
errorType'error' | 'warning' | 'info'Alert visual style'error'

Exported 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>
  • If the current user can view requiredRoles → returns undefined (the page continues).
  • If the user cannot → returns a redirect 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 CodeMessage
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

When the visitor is authorized, the alert message resolves in this order:

  1. Custom errorMessage prop (highest priority)
  2. Predefined error code mapping (from URL ?error=... param)
  3. Raw error parameter value (fallback for unknown codes)
  4. No alert (when neither prop nor param is present)

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:

  • ✅ Declarative and self-documenting
  • ✅ Real redirect for unauthorized users (via the frontmatter guard)
  • ✅ Deny-in-place backstop on the layout
  • ✅ Protected data is never fetched or serialized for a denied user

Best For: entire-page protection; pages that fetch protected data in frontmatter.

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-safe
const requiredRoles: AnyRole[] = ['admin', 'super_admin']
// ❌ INCORRECT - No type checking
const 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 data
const 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 it
const 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.

  1. Test with the correct role:

    • Assign yourself the required role in the Clerk dashboard
    • Navigate to the protected page
    • Verify the page renders normally
  2. Test without the required role (frontmatter guard present):

    • Remove the required role from your account
    • Navigate to the protected page
    • Verify you are redirected to /dashboard?error=insufficient_permissions
    • Check the error message displays on the destination
  3. Test deny-in-place (layout prop only, no frontmatter guard):

    • On a page that omits redirectUnauthorized(), navigate without the required role
    • Verify the response is a 200 showing the “insufficient permissions” alert
    • Confirm the guarded content (and its island data) is absent from the HTML

The 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:

  1. Check the role configuration in config/roles.config.ts
  2. Verify your role assignment in the Clerk dashboard
  3. The role cache has a 1-minute TTL—wait, or clear it with clearRoleCache(userId)
  4. Check for typos in role names, or that you are using the shared constant

Denial Shows an Alert Instead of Redirecting

Section titled “Denial Shows an Alert Instead of Redirecting”

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.

Protected Data Still Fetched for Denied Users

Section titled “Protected Data Still Fetched for Denied Users”

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.

src/pages/api/admin/settings.ts
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' },
})
}
  • Layout guard: src/layouts/Auth.astro — the denied computation and the {!denied && <slot />} withholding.
  • Redirect helper: redirectUnauthorized() in src/utils/role-guard.ts.
  • Access tiers: src/constants/service-day-access.ts.
  • Canonical callers: 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.
  • Access tests: tests/utils/service-day-access.test.ts.
  • Type definitions: src/types/generated-roles.ts.