Skip to content

Role Guard Usage Guide

The role-based visibility system provides a unified API for controlling content access based on user roles. Supports both Supabase user roles (app-level) and Clerk organization roles (org-level) through a single, consistent interface.

Version: 1.0 | Status: ✅ Production Ready

Looking for page-level protection? See the dedicated Page-Level Protection Guide for complete coverage of requireRole() with hierarchical privilege escalation.

The role guard system provides:

  • Unified API for Supabase and Clerk roles
  • Server-side security with zero client JavaScript
  • Automatic caching with configurable TTL (default: 1 minute)
  • Type safety with full TypeScript support
  • Flexible usage - utilities and components
  • Well-tested - 54+ unit and component tests

🛡️ Utility Functions

Use canViewContent(), requireRole(), and getUserRole() for programmatic role checks with detailed results.

🎨 Wrapper Components

Use <RoleGuard> components in Astro and React to hide/show content based on user roles with zero configuration.

⚡ Performance

Built-in caching with 1-minute TTL reduces database queries. Configure cache duration per check.

🔍 Debug Mode

Development-only debug mode shows role checking details to help troubleshoot access issues.

src/pages/admin.astro
---
import RoleGuard from '#components/astro/RoleGuard.astro'
---
<RoleGuard allowedRoles={['admin', 'super_admin']}>
<h1>Admin Dashboard</h1>
<p>Only admins can see this content</p>
</RoleGuard>

That’s it! The component handles everything:

  • ✅ Fetches user role from Astro.locals or Supabase
  • ✅ Checks if user has any allowed role
  • ✅ Hides content if unauthorized
  • ✅ Caches role queries (1-minute TTL)

The astro-basics project uses two independent role systems:

Storage: Supabase users table Sync Source: Clerk webhook → Supabase Roles: member, admin, super_admin

// Determines app-wide privileges
type UserRole = 'member' | 'admin' | 'super_admin'

Use Cases:

  • Global admin features
  • User management
  • System configuration
  • Cross-organization permissions

Storage: Clerk session claims (Astro.locals.userRole) Roles: org:admin, org:member

// Determines organization-specific privileges
type OrgRole = 'org:admin' | 'org:member'

Use Cases:

  • Organization settings
  • Team management
  • Org-specific features
  • Multi-tenant access control

The role guard system works with both role systems through a unified API:

type AnyRole = UserRole | OrgRole
// Can be: 'member' | 'admin' | 'super_admin' | 'org:admin' | 'org:member'

Primary role-checking function - Returns boolean indicating if user can view content.

canViewContent(
locals: App.Locals,
allowedRoles: AnyRole[],
options?: Partial<RoleGuardConfig>
): Promise<boolean>
---
import { canViewContent } from '#utils/role-guard'
const canViewAdmin = await canViewContent(Astro.locals, ['admin', 'super_admin'])
---
{canViewAdmin && <AdminPanel />}

Return Value:

  • true - User has at least ONE of the allowed roles
  • false - User lacks all allowed roles OR not authenticated

Enhanced version - Returns detailed authorization result with debugging info.

canViewContentDetailed(
locals: App.Locals,
allowedRoles: AnyRole[],
options?: Partial<RoleGuardConfig>
): Promise<RoleCheckResult>
---
import { canViewContentDetailed } from '#utils/role-guard'
const result = await canViewContentDetailed(Astro.locals, ['admin'])
if (!result.allowed) {
console.log('Access denied:', result.reason)
console.log('User role:', result.userRole)
}
---

Page-level protection - Throws 403 error if user lacks required role.

requireRole(
locals: App.Locals,
allowedRoles: AnyRole[],
options?: Partial<RoleGuardConfig>
): Promise<void>
src/pages/super-admin/index.astro
---
import { requireRole } from '#utils/role-guard'
// Protect entire page - throws if unauthorized
await requireRole(Astro.locals, ['super_admin'])
---
<html>
<body>
<h1>Super Admin Dashboard</h1>
<!-- All content protected - only renders if check passes -->
</body>
</html>

Extracts user role from Astro.locals with optional Supabase fallback.

getUserRole(locals: App.Locals, fetchFromSupabase?: boolean): Promise<AnyRole | null>
---
import { getUserRole } from '#utils/role-guard'
// Get org role from Clerk session (if available)
const orgRole = await getUserRole(Astro.locals, false)
// Get user role from Supabase (with fallback)
const userRole = await getUserRole(Astro.locals, true)
console.log('Org role:', orgRole) // e.g., 'org:admin' or null
console.log('User role:', userRole) // e.g., 'admin' or 'member'
---

Fetch Priority:

  1. Astro.locals.userRole (Clerk org role from middleware)
  2. Supabase query (if fetchFromSupabase === true)
  3. null (if not authenticated)

Semantic aliases for role checking with explicit OR/AND logic.

hasAnyRole(locals: App.Locals, roles: AnyRole[]): Promise<boolean>
hasAllRoles(locals: App.Locals, roles: AnyRole[]): Promise<boolean>
---
import { hasAnyRole } from '#utils/role-guard'
// User needs to be admin OR super_admin
const isStaff = await hasAnyRole(Astro.locals, ['admin', 'super_admin'])
---
{isStaff && <StaffTools />}

Type guard - Validates if string is a valid role.

const userInput = 'admin'
if (isValidRole(userInput)) {
// TypeScript now knows userInput is AnyRole
const label = ROLE_LABELS[userInput]
}

Human-readable labels - Converts role identifiers to display names.

formatRoleForDisplay('super_admin') // Returns: "Super Admin"
formatRoleForDisplay('org:admin') // Returns: "Organization Admin"
formatRoleForDisplay('member') // Returns: "Member"

Server-side component for Astro pages - performs authorization server-side (zero client JavaScript).

interface Props {
allowedRoles: AnyRole[] // Required - roles that can view content
fallback?: string // Optional - message when access denied
debug?: boolean // Optional - show debug info (dev only)
fetchFromSupabase?: boolean // Optional - query Supabase (default: true)
}
---
import RoleGuard from '#components/astro/RoleGuard.astro'
---
<RoleGuard allowedRoles={['admin', 'super_admin']}>
<div class="admin-panel">
<h2>Admin Controls</h2>
<button>Delete All Users</button>
</div>
</RoleGuard>

Client-side component for React - requires server-fetched role data as props.

interface RoleGuardProps {
userRole: AnyRole | null // Required - pre-fetched server-side
allowedRoles: AnyRole[] // Required - roles that can view content
children: ReactNode // Required - content to protect
fallback?: ReactNode // Optional - shown when access denied
loading?: boolean // Optional - show loading state
className?: string // Optional - custom CSS class
'data-testid'?: string // Optional - for testing
}
src/pages/dashboard.astro
---
import Dashboard from '#components/react/Dashboard'
import { getUserRole } from '#utils/role-guard'
// Fetch role server-side
const userRole = await getUserRole(Astro.locals, true)
---
<Dashboard userRole={userRole} client:load />

Show different content based on role hierarchy.

---
import { canViewContent } from '#utils/role-guard'
const canViewBasic = await canViewContent(Astro.locals, ['member', 'admin', 'super_admin'])
const canViewPremium = await canViewContent(Astro.locals, ['admin', 'super_admin'])
const canViewAdmin = await canViewContent(Astro.locals, ['super_admin'])
---
{
canViewBasic && (
<section>
<h2>Basic Content</h2>
<p>All authenticated users can see this</p>
</section>
)
}
{
canViewPremium && (
<section>
<h2>Premium Content</h2>
<p>Only admins and super admins can see this</p>
</section>
)
}
{
canViewAdmin && (
<section>
<h2>Super Admin Content</h2>
<p>Highest privilege only</p>
</section>
)
}

Support both Supabase and Clerk roles in same page.

<RoleGuard
allowedRoles={[
'super_admin', // Supabase user role
'org:admin', // Clerk org role
]}
>
<AdvancedSettings />
</RoleGuard>

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

See Page-Level Protection Guide for more examples and security best practices.

---
import { requireRole } from '#utils/role-guard'
import RoleGuard from '#components/astro/RoleGuard.astro'
// Page-level: Only admins can access page at all
await requireRole(Astro.locals, ['admin', 'super_admin'])
---
<html>
<body>
<h1>Admin Dashboard</h1>
<!-- Component-level: Only super_admins see danger zone -->
<RoleGuard allowedRoles={['super_admin']}>
<DangerZone />
</RoleGuard>
</body>
</html>

Show/hide navigation items based on role.

---
import { canViewContent } from '#utils/role-guard'
const canViewAdmin = await canViewContent(Astro.locals, ['admin', 'super_admin'])
const canViewOrg = await canViewContent(Astro.locals, ['org:admin'])
---
<nav>
<a href="/">Home</a>
<a href="/dashboard">Dashboard</a>
{canViewOrg && <a href="/organization">Organization</a>}
{canViewAdmin && <a href="/admin">Admin</a>}
</nav>

Hide sensitive form fields based on role.

function UserForm({ userRole }: Props) {
return (
<form>
<input name="username" placeholder="Username" />
<input name="email" type="email" placeholder="Email" />
{/* Only admins can change roles */}
<RoleGuard userRole={userRole} allowedRoles={['admin', 'super_admin']}>
<select name="role">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</RoleGuard>
</form>
)
}

Default cache TTL is 1 minute. Adjust based on your needs:

---
// Short TTL (30 seconds) for frequently changing roles
const canView = await canViewContent(Astro.locals, ['admin'], { cacheTTL: 30000 })
// Long TTL (5 minutes) for stable roles
const canView = await canViewContent(Astro.locals, ['admin'], { cacheTTL: 300000 })
---

Clerk org roles are in Astro.locals (no DB query). Use them when possible:

---
// ✅ Fast - uses Astro.locals (org role)
const canManageOrg = await canViewContent(Astro.locals, ['org:admin'], {
fetchFromSupabase: false,
})
// ⚠️ Slower - queries Supabase
const isAdmin = await canViewContent(Astro.locals, ['admin'], { fetchFromSupabase: true })
---

For protected routes, pre-fetch Supabase roles in middleware:

// src/middleware.ts (enhancement)
if (isProtectedRoute(context.request)) {
locals.supabaseRole = await fetchSupabaseRole(locals.userId)
}

Then role guards skip the DB query entirely.

When rendering lists, fetch role once and reuse:

---
import { getUserRole } from '#utils/role-guard'
const userRole = await getUserRole(Astro.locals, true)
const features = [
{ name: 'Basic Feature', allowedRoles: ['member', 'admin', 'super_admin'] },
{ name: 'Admin Feature', allowedRoles: ['admin', 'super_admin'] },
{ name: 'Super Feature', allowedRoles: ['super_admin'] },
]
---
{
features.map(
feature =>
userRole && feature.allowedRoles.includes(userRole) && <FeatureCard name={feature.name} />
)
}

Symptoms: Content hidden even though user has correct role

Causes:

  1. Supabase role not synced from Clerk
  2. Typo in role name
  3. Cache showing stale data

Solutions:

---
// Enable debug mode to see what's happening
---
<RoleGuard allowedRoles={['admin']} debug={true}>
<Content />
</RoleGuard>
<!-- Check output:
User Role: member (expected: admin)
Allowed Roles: admin
Access: Denied
-->

Symptoms: PGRST116 error or null role returned

Cause: Clerk user not yet synced to Supabase database

Solution: User creation happens automatically via webhook. The UserInfo.astro component handles this with upsert logic (see: src/components/astro/UserInfo.astro:55-64).

Symptoms: RoleGuard in React shows nothing

Cause: userRole prop not passed from server

Solution:

---
// WRONG - userRole not fetched
;<Dashboard client:load />
// RIGHT - fetch server-side and pass as prop
import { getUserRole } from '#utils/role-guard'
const userRole = await getUserRole(Astro.locals, true)
---
<Dashboard userRole={userRole} client:load />

Symptoms: Page loads slowly with many role checks

Solutions:

  1. Reduce cache TTL (if roles change frequently)
  2. Increase cache TTL (if roles are stable)
  3. Pre-fetch roles in middleware for protected routes
  4. Use Clerk org roles instead of Supabase roles when possible
// Monitor cache performance
import { getRoleCacheStats } from '#utils/role-guard'
const stats = getRoleCacheStats()
console.log('Cache entries:', stats.entries, 'Size:', stats.size)
---
const supabase = getSupabaseServiceRole()
const { data } = await supabase.from('users').select('role').eq('clerk_id', userId).single()
const isAdmin = data?.role === 'admin' || data?.role === 'super_admin'
---
{isAdmin && <AdminPanel />}

Benefits:

  • ✅ 80% less code
  • ✅ Type-safe
  • ✅ Automatic caching
  • ✅ Better error handling
  • ✅ Consistent with project patterns
  1. Never trust client-side - Always fetch roles server-side
  2. Defense in depth - Combine role guards with RLS policies and middleware
  3. Audit logging - Use canViewContentDetailed() for security audits
  4. Principle of least privilege - Grant minimum necessary permissions
  1. Component-level guards for granular UI control
  2. Page-level guards (requireRole) for route protection
  3. Middleware guards for API endpoint protection
  4. Consistent role naming - Use constants from #utils/role-types
  1. Mock Astro.locals in tests
  2. Test both authorized and unauthorized cases
  3. Verify fallback content renders
  4. Check accessibility attributes (role, aria-*)

The role-based visibility system provides:

🎯 Unified API

Single interface for both Supabase and Clerk roles

🔒 Server-Side Security

Zero client JavaScript, all checks happen server-side

⚡ Auto-Caching

Configurable cache TTL (default: 1 minute)

🛡️ Type Safety

Full TypeScript support with strict type checking

🧪 Well-Tested

54+ unit and component tests ensure reliability

👨‍💻 Developer-Friendly

Matches existing project patterns and conventions

Quick Reference:

  • Simple checks: canViewContent()
  • Page protection: requireRole()
  • Debugging: canViewContentDetailed()
  • Astro components: <RoleGuard>
  • React components: <RoleGuard userRole={...}>

For implementation details, see the role-based visibility system implementation plan.