Skip to content

Page-Level Protection with Role Guards

Protect entire pages from unauthorized access using the requireRole() function. This guide covers hierarchical privilege escalation, exact role matching, and best practices for securing routes in your Astro application.

Quick Link: For component-level protection, see Role Guard Usage Guide

Page-level protection blocks unauthorized users from accessing entire pages by throwing a 403 Forbidden error during server-side rendering. Unlike component-level guards that hide specific UI elements, page protection prevents the entire page from rendering if the user lacks required permissions.

🔒 Complete Page Blocking

Entire page is protected - unauthorized users see 403 error instead of any page content

📊 Hierarchical Access

Higher-level roles automatically inherit lower-level permissions (configurable)

⚡ Server-Side Security

Authorization happens during SSR - zero client-side JavaScript or data exposure

🎯 Flexible Matching

Choose between hierarchical checking (default) or exact role matching

✅ Use requireRole() for:

  • Admin dashboards and super admin pages
  • Entire sections requiring specific roles (e.g., /organization/*)
  • Pages with sensitive data that shouldn’t be rendered at all for unauthorized users
  • API routes and server endpoints requiring authentication

❌ Use <RoleGuard> component instead for:

  • Hiding specific UI sections within a page
  • Conditional feature visibility (e.g., admin buttons)
  • Mixed-audience pages where some content is public

Default Behavior: Higher-privilege roles automatically gain access to content restricted to lower roles.

Role Hierarchy (from config/roles.config.ts):

  • member → Level 1 (lowest privilege)
  • admin → Level 2 (can access member content + admin content)
  • super_admin → Level 3 (can access all content)
// With hierarchical checking (default)
await requireRole(Astro.locals, ['member'])
// ✅ Allows: member, admin, super_admin (all equal or higher)
// With exact role matching
await requireRole(Astro.locals, ['member'], { useHierarchy: false })
// ✅ Allows: member ONLY
// ❌ Blocks: admin, super_admin

Organization roles (org:admin, org:member) from Clerk use flat matching only - no hierarchy applies:

await requireRole(Astro.locals, ['org:admin'])
// ✅ Allows: org:admin
// ❌ Blocks: org:member (no hierarchy for org roles)
requireRole(
locals: App.Locals,
allowedRoles: AnyRole[],
options?: Partial<RoleGuardConfig>
): Promise<void>

Type: App.Locals

Astro.locals object containing authentication state:

  • locals.userId - Current user’s Clerk ID
  • locals.userRole - Organization role (if in org context)
---
await requireRole(Astro.locals, ['admin'])
---

requireRole() throws an error if authorization fails:

  • User authorized → Returns void (page renders normally)
  • User unauthorized → Throws Error: 403 Forbidden: ...
  • Not authenticated → Throws Error: 403 Forbidden: User not authenticated

Astro automatically converts thrown errors to error pages. Create src/pages/403.astro for custom forbidden pages.

Protect a page allowing admin and super_admin roles:

src/pages/admin/index.astro
---
import { requireRole } from '#utils/role-guard'
// Hierarchical check (default) - super_admin can also access
await requireRole(Astro.locals, ['admin'])
---
<html>
<body>
<h1>Admin Dashboard</h1>
<p>Only admins and super admins can see this page</p>
</body>
</html>

Who can access:

  • admin (explicitly allowed)
  • super_admin (higher level, inherits access)
  • member (lower level, blocked)

Create exclusive access for a specific role:

src/pages/members-lounge/index.astro
---
import { requireRole } from '#utils/role-guard'
// Exact match only - admins NOT allowed
await requireRole(Astro.locals, ['member'], {
useHierarchy: false
})
---
<html>
<body>
<h1>Members-Only Lounge</h1>
<p>Exclusively for regular members - admins use admin portal</p>
</body>
</html>

Who can access:

  • member (exact match)
  • admin (hierarchy disabled)
  • super_admin (hierarchy disabled)

Allow any of several roles (OR logic):

src/pages/staff/index.astro
---
import { requireRole } from '#utils/role-guard'
// User needs to be admin OR super_admin (or higher with hierarchy)
await requireRole(Astro.locals, ['admin', 'super_admin'])
---
<html>
<body>
<h1>Staff Portal</h1>
<p>Administrative staff only</p>
</body>
</html>

Who can access:

  • admin (explicitly allowed)
  • super_admin (explicitly allowed)
  • member (not in allowed roles)

Protect organization-specific pages:

src/pages/organization/settings/index.astro
---
import { requireRole } from '#utils/role-guard'
// Organization admins only (from Clerk session)
await requireRole(Astro.locals, ['org:admin'], {
fetchFromSupabase: false // Org roles in Astro.locals, no DB query needed
})
---
<html>
<body>
<h1>Organization Settings</h1>
<p>Configure your organization</p>
</body>
</html>

Allow both app-level and org-level roles:

src/pages/admin/organizations/index.astro
---
import { requireRole } from '#utils/role-guard'
// Super admins OR org admins can access
await requireRole(Astro.locals, ['super_admin', 'org:admin'])
---
<html>
<body>
<h1>Organization Management</h1>
<p>Manage organizations (super admin) or your own org (org admin)</p>
</body>
</html>

Complete admin dashboard with hierarchical access:

src/pages/admin/dashboard.astro
---
import { requireRole } from '#utils/role-guard'
import AdminLayout from '#/layouts/AdminLayout.astro'
// Admin and super_admin can access (hierarchical)
await requireRole(Astro.locals, ['admin'])
// Page-specific data fetching (only executes if authorized)
const stats = await fetchAdminStats()
const recentActivity = await fetchRecentActivity()
---
<AdminLayout title="Admin Dashboard">
<h1>Admin Dashboard</h1>
<section class="stats">
<h2>System Statistics</h2>
<StatsCards stats={stats} />
</section>
<section class="activity">
<h2>Recent Activity</h2>
<ActivityFeed activities={recentActivity} />
</section>
</AdminLayout>

★ Insight ─────────────────────────────────────

  1. Security First: requireRole() executes BEFORE any data fetching, preventing unauthorized data access
  2. No Cleanup Needed: If authorization fails, Astro stops execution - no need to handle error states in component logic ─────────────────────────────────────────────────

Pages that ONLY super admins should access:

src/pages/super-admin/system-config.astro
---
import { requireRole } from '#utils/role-guard'
// ONLY super_admin - exact match
await requireRole(Astro.locals, ['super_admin'], {
useHierarchy: false // Explicitly disable hierarchy for clarity
})
// Or use hierarchical (same effect since super_admin is highest)
// await requireRole(Astro.locals, ['super_admin'])
---
<html>
<body>
<h1>System Configuration</h1>
<p>Dangerous settings - super admins only</p>
<form method="post">
<button type="submit">Reset All User Data</button>
<button type="submit">Modify System Settings</button>
</form>
</body>
</html>

Pattern 3: Nested Protection (Page + Component)

Section titled “Pattern 3: Nested Protection (Page + Component)”

Combine page-level and component-level guards for granular control:

src/pages/admin/users.astro
---
import { requireRole } from '#utils/role-guard'
import RoleGuard from '#components/astro/RoleGuard.astro'
// Page-level: Admin and super_admin can view page
await requireRole(Astro.locals, ['admin'])
const users = await fetchAllUsers()
---
<html>
<body>
<h1>User Management</h1>
<!-- All admins see user list -->
<section class="user-list">
<UserTable users={users} />
</section>
<!-- Component-level: ONLY super_admin sees danger zone -->
<RoleGuard allowedRoles={['super_admin']}>
<section class="danger-zone">
<h2>Danger Zone</h2>
<button>Delete All Users</button>
<button>Reset Database</button>
</section>
</RoleGuard>
</body>
</html>

Access Matrix:

Role View Page? See User List? Access Danger Zone?
member ❌ No ❌ No ❌ No
admin ✅ Yes ✅ Yes ❌ No
super_admin ✅ Yes ✅ Yes ✅ Yes

Create a branded error page for unauthorized access:

src/pages/403.astro
---
import BaseLayout from '#/layouts/BaseLayout.astro'
// Extract error details from Astro (if available)
const error = Astro.props.error as Error | undefined
const message = error?.message || 'You do not have permission to access this page.'
---
<BaseLayout title="Access Denied - 403">
<main class="error-page">
<div class="error-container">
<h1>🔒 Access Denied</h1>
<p class="error-code">Error 403 - Forbidden</p>
<p class="error-message">{message}</p>
<div class="actions">
<a href="/dashboard" class="btn btn-primary">
Go to Dashboard
</a>
<a href="/support" class="btn btn-secondary">
Contact Support
</a>
</div>
</div>
</main>
</BaseLayout>
<style>
.error-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 70vh;
text-align: center;
}
.error-container {
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 1.25rem;
color: #dc3545;
font-weight: 600;
margin: 1rem 0;
}
.error-message {
font-size: 1.125rem;
color: #6c757d;
margin: 1.5rem 0;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
}
</style>

★ Insight ───────────────────────────────────── Astro automatically routes 403 errors to src/pages/403.astro. The error object contains the exact reason for denial, which you can display or log for debugging. ─────────────────────────────────────────────────

Combine multiple security layers - never rely on client-side protection alone:

src/pages/api/admin/delete-user.ts
---
import type { APIRoute } from 'astro'
import { requireRole } from '#utils/role-guard'
export const POST: APIRoute = async ({ locals }) => {
// Layer 1: Page-level role check
await requireRole(locals, ['super_admin'])
// Layer 2: Additional authorization logic
const canDelete = await checkUserDeletionPermissions(locals.userId)
if (!canDelete) {
return new Response('Insufficient permissions', { status: 403 })
}
// Layer 3: Database RLS policies (Supabase)
// These run automatically based on service role configuration
// Perform deletion
const result = await deleteUser(userId)
return new Response(JSON.stringify(result), { status: 200 })
}
---

Layer 1: Role Guards

requireRole() blocks unauthorized access at the route level

Layer 2: Business Logic

Additional permission checks specific to the operation

Layer 3: Database RLS

Supabase Row Level Security policies as final enforcement


For entire route sections, use middleware to protect multiple pages:

src/middleware.ts
import { sequence } from 'astro:middleware'
import { clerkMiddleware } from '@clerk/astro/middleware'
const protectedRoutes = [
'/admin',
'/super-admin',
'/api/admin',
]
const authCheck = async (context, next) => {
const { request, locals } = context
const url = new URL(request.url)
// Pre-check authentication for protected routes
if (protectedRoutes.some(route => url.pathname.startsWith(route))) {
if (!locals.userId) {
return new Response('Unauthorized', { status: 401 })
}
}
return next()
}
export const onRequest = sequence(clerkMiddleware(), authCheck)

Then use requireRole() for fine-grained role checking:

src/pages/admin/settings.astro
---
import { requireRole } from '#utils/role-guard'
// Middleware ensures authentication, requireRole enforces specific role
await requireRole(Astro.locals, ['admin', 'super_admin'])
---

Use canViewContentDetailed() for audit trails:

src/pages/admin/sensitive-data.astro
---
import { canViewContentDetailed } from '#utils/role-guard'
const authResult = await canViewContentDetailed(
Astro.locals,
['super_admin']
)
// Log access attempts
if (!authResult.allowed) {
await logSecurityEvent({
event: 'unauthorized_access_attempt',
userId: Astro.locals.userId,
userRole: authResult.userRole,
requestedPage: Astro.url.pathname,
reason: authResult.reason,
timestamp: new Date().toISOString(),
})
throw new Error(`403 Forbidden: ${authResult.reason}`)
}
// Log successful access
await logSecurityEvent({
event: 'authorized_access',
userId: Astro.locals.userId,
userRole: authResult.userRole,
page: Astro.url.pathname,
timestamp: new Date().toISOString(),
})
---

Disable certain pages in production:

src/pages/admin/debug.astro
---
import { requireRole } from '#utils/role-guard'
// Development only
if (import.meta.env.PROD) {
throw new Error('403 Forbidden: Debug page not available in production')
}
await requireRole(Astro.locals, ['super_admin'])
---
<html>
<body>
<h1>Debug Tools</h1>
<p>Development environment only</p>
</body>
</html>

Issue: “403 Forbidden” Despite Having Correct Role

Section titled “Issue: “403 Forbidden” Despite Having Correct Role”

Symptoms: User with correct role sees 403 error

Possible Causes:

  1. Role not synced to Supabase

    ---
    // Check user's actual role
    import { getUserRole } from '#utils/role-guard'
    const currentRole = await getUserRole(Astro.locals, true)
    console.log('Current role:', currentRole) // Check server logs
    ---
  2. Stale cache

    // Clear role cache for specific user
    import { clearRoleCache } from '#utils/role-guard'
    clearRoleCache(locals.userId)
  3. Wrong role type (user role vs org role)

    ---
    // Check BOTH role sources
    const userRole = await getUserRole(Astro.locals, true) // Supabase
    const orgRole = await getUserRole(Astro.locals, false) // Clerk
    console.log({ userRole, orgRole })
    ---

Issue: Page Loads Blank Instead of 403 Error

Section titled “Issue: Page Loads Blank Instead of 403 Error”

Cause: JavaScript error or missing error boundary

Solution: Check browser console and server logs:

---
try {
await requireRole(Astro.locals, ['admin'])
} catch (error) {
console.error('[Page Protection] Authorization failed:', error)
throw error // Re-throw to trigger Astro's error handling
}
---

Ensure src/pages/403.astro exists for custom error pages.


Issue: Performance Degradation with Many Protected Pages

Section titled “Issue: Performance Degradation with Many Protected Pages”

Symptoms: Slow page loads across protected routes

Solutions:

---
// Cache role for 5 minutes (default: 1 minute)
await requireRole(Astro.locals, ['admin'], {
cacheTTL: 300000
})
---
Need to protect content?
├─ Entire page/route?
│ └─ Use requireRole() in page frontmatter
│ ├─ Higher roles should access? → useHierarchy: true (default)
│ └─ Exact role only? → useHierarchy: false
├─ Specific UI components?
│ └─ Use <RoleGuard> component
│ └─ See: /guide/role-guard-usage/
└─ API endpoint?
└─ Use requireRole() in API route handler
└─ Combine with additional authorization checks
Option Default Use Case
useHierarchy: true Higher roles inherit lower-role access
useHierarchy: false - Exact role matching only
fetchFromSupabase: true Query Supabase for user roles
fetchFromSupabase: false - Use only Clerk org roles (faster)
cacheTTL: 60000 1-minute cache (balance fresh vs performance)
cacheTTL: 300000 - 5-minute cache (stable roles)
cacheTTL: 0 - No cache (always fresh, slower)
---
// Members and above (most common)
await requireRole(Astro.locals, ['member'])
// Admin features
await requireRole(Astro.locals, ['admin', 'super_admin'])
// Super admin only
await requireRole(Astro.locals, ['super_admin'])
// Organization admins
await requireRole(Astro.locals, ['org:admin'])
// Super admins OR org admins (mixed)
await requireRole(Astro.locals, ['super_admin', 'org:admin'])
// Members ONLY (exclude admins)
await requireRole(Astro.locals, ['member'], { useHierarchy: false })
---

Page-level protection with requireRole() provides:

🛡️ Complete Security

Entire pages protected server-side before any rendering or data fetching occurs

📊 Flexible Hierarchy

Choose between hierarchical privilege escalation or exact role matching per route

⚡ High Performance

Built-in caching with configurable TTL reduces database queries while maintaining security

🔍 Full Debugging

Detailed error messages and audit logging capabilities for security monitoring

Related Guides: