🔒 Complete Page Blocking
Entire page is protected - unauthorized users see 403 error instead of any page content
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:
/organization/*)❌ Use <RoleGuard> component instead for:
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 matchingawait requireRole(Astro.locals, ['member'], { useHierarchy: false })// ✅ Allows: member ONLY// ❌ Blocks: admin, super_adminOrganization 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() FunctionrequireRole( 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 IDlocals.userRole - Organization role (if in org context)---await requireRole(Astro.locals, ['admin'])---Type: AnyRole[]
Array of roles that can access the page. User must have at least ONE role (with hierarchy) or exact match (without hierarchy).
Available Roles:
'member' | 'admin' | 'super_admin''org:admin' | 'org:member'---// Single roleawait requireRole(Astro.locals, ['super_admin'])
// Multiple roles (OR logic)await requireRole(Astro.locals, ['admin', 'super_admin'])
// Mixed role typesawait requireRole(Astro.locals, ['super_admin', 'org:admin'])---Type: Partial<RoleGuardConfig>
Optional configuration object:
interface RoleGuardConfig { fetchFromSupabase?: boolean // Query Supabase for user role (default: true) cacheTTL?: number // Cache duration in ms (default: 60000) useHierarchy?: boolean // Enable hierarchical checking (default: true)}Examples:
---// Exact role matching onlyawait requireRole(Astro.locals, ['member'], { useHierarchy: false})
// Skip Supabase query (org roles only)await requireRole(Astro.locals, ['org:admin'], { fetchFromSupabase: false})
// Custom cache duration (5 minutes)await requireRole(Astro.locals, ['admin'], { cacheTTL: 300000})---requireRole() throws an error if authorization fails:
void (page renders normally)Error: 403 Forbidden: ...Error: 403 Forbidden: User not authenticatedAstro 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:
---import { requireRole } from '#utils/role-guard'
// Hierarchical check (default) - super_admin can also accessawait 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:
---import { requireRole } from '#utils/role-guard'
// Exact match only - admins NOT allowedawait 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):
---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:
---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:
---import { requireRole } from '#utils/role-guard'
// Super admins OR org admins can accessawait 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:
---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 ─────────────────────────────────────
requireRole() executes BEFORE any data fetching, preventing unauthorized data access─────────────────────────────────────────────────Pages that ONLY super admins should access:
---import { requireRole } from '#utils/role-guard'
// ONLY super_admin - exact matchawait 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>Combine page-level and component-level guards for granular control:
---import { requireRole } from '#utils/role-guard'import RoleGuard from '#components/astro/RoleGuard.astro'
// Page-level: Admin and super_admin can view pageawait 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:
---import BaseLayout from '#/layouts/BaseLayout.astro'
// Extract error details from Astro (if available)const error = Astro.props.error as Error | undefinedconst 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:
---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:
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:
---import { requireRole } from '#utils/role-guard'
// Middleware ensures authentication, requireRole enforces specific roleawait requireRole(Astro.locals, ['admin', 'super_admin'])---Use canViewContentDetailed() for audit trails:
---import { canViewContentDetailed } from '#utils/role-guard'
const authResult = await canViewContentDetailed( Astro.locals, ['super_admin'])
// Log access attemptsif (!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 accessawait logSecurityEvent({ event: 'authorized_access', userId: Astro.locals.userId, userRole: authResult.userRole, page: Astro.url.pathname, timestamp: new Date().toISOString(),})---Disable certain pages in production:
---import { requireRole } from '#utils/role-guard'
// Development onlyif (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>Symptoms: User with correct role sees 403 error
Possible Causes:
Role not synced to Supabase
---// Check user's actual roleimport { getUserRole } from '#utils/role-guard'const currentRole = await getUserRole(Astro.locals, true)console.log('Current role:', currentRole) // Check server logs---Stale cache
// Clear role cache for specific userimport { clearRoleCache } from '#utils/role-guard'clearRoleCache(locals.userId)Wrong role type (user role vs org role)
---// Check BOTH role sourcesconst userRole = await getUserRole(Astro.locals, true) // Supabaseconst orgRole = await getUserRole(Astro.locals, false) // Clerkconsole.log({ userRole, orgRole })---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.
Symptoms: Slow page loads across protected routes
Solutions:
---// Cache role for 5 minutes (default: 1 minute)await requireRole(Astro.locals, ['admin'], { cacheTTL: 300000})---// Fetch role once in middleware for protected routesif (protectedRoutes.includes(url.pathname)) { const { getUserRole } = await import('#utils/role-guard') locals.cachedRole = await getUserRole(locals, true)}---// Org roles in Astro.locals (no DB query)await requireRole(Astro.locals, ['org:admin'], { fetchFromSupabase: false})---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 featuresawait requireRole(Astro.locals, ['admin', 'super_admin'])
// Super admin onlyawait requireRole(Astro.locals, ['super_admin'])
// Organization adminsawait 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: