Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Configure enterprise SSO, role-based access control, and organization management. Use when implementing SSO integration, configuring role-based permissions, or setting up organization-level controls. Trigger with phrases like "clerk SSO", "clerk RBAC", "clerk enterprise", "clerk roles", "clerk permissions", "clerk organizations".
.claude/skills/jeremylongshore-clerk-enterprise-rbac/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 69% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 82% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 95% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 207% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 83% | 0% |
Implement enterprise-grade role-based access control, organization management, and SSO with Clerk. Covers custom roles and permissions, organization lifecycle, multi-tenant access patterns, SAML/OIDC SSO, and the Backend API for programmatic role management (released Nov 2025).
@clerk/nextjs)typescript// app/org-selector/page.tsx import { OrganizationSwitcher, OrganizationProfile } from '@clerk/nextjs' export default function OrgPage() { return ( <div className="p-8"> <h1>Select Organization</h1> <OrganizationSwitcher hidePersonal={false} afterSelectOrganizationUrl="/dashboard" afterCreateOrganizationUrl="/dashboard" /> <div className="mt-8"> <OrganizationProfile /> </div> </div> ) }
Configure in Clerk Dashboard > Organizations > Roles and Permissions.
Default roles (built-in):
| Role | Key | Built-in Permissions | |------|-----|---------------------| | Admin | org:admin | Full org management (members, settings, billing) | | Member | org:member | View org, read-only access |
Custom permissions (create in Dashboard > Organizations > Permissions):
| Permission | Key | Description | |------------|-----|-------------| | Read data | org:data:read | View organization resources | | Write data | org:data:write | Create/update resources | | Delete data | org:data:delete | Delete resources | | Manage billing | org:billing:manage | Access billing settings | | View analytics | org:analytics:read | Access analytics dashboard |
Custom roles (create in Dashboard > Organizations > Roles):
| Role | Permissions | Use Case | |------|-------------|----------| | org:manager | data:read, data:write, analytics:read | Content managers | | org:viewer | data:read | Read-only stakeholders | | org:billing_admin | data:read, billing:manage | Finance team |
typescript// middleware.ts import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' const isPublicRoute = createRouteMatcher([ '/', '/sign-in(.*)', '/sign-up(.*)', '/api/webhooks(.*)', ]) const isAdminRoute = createRouteMatcher(['/admin(.*)']) const isManagerRoute = createRouteMatcher(['/manage(.*)']) export default clerkMiddleware(async (auth, req) => { if (isPublicRoute(req)) return if (isAdminRoute(req)) { // Only org:admin can access /admin/* await auth.protect({ role: 'org:admin' }) } else if (isManagerRoute(req)) { // org:admin OR org:manager can access /manage/* await auth.protect((has) => has({ role: 'org:admin' }) || has({ role: 'org:manager' }) ) } else { // All other routes just require authentication await auth.protect() } })
typescript// app/admin/page.tsx import { auth } from '@clerk/nextjs/server' import { redirect } from 'next/navigation' export default async function AdminPage() { const { userId, orgId, orgRole, has } = await auth() if (!userId) redirect('/sign-in') if (!orgId) redirect('/org-selector') // Permission-based checks (preferred over role-based) const canManageMembers = has({ permission: 'org:sys_memberships:manage' }) const canWriteData = has({ permission: 'org:data:write' }) const canDeleteData = has({ permission: 'org:data:delete' }) const canViewAnalytics = has({ permission: 'org:analytics:read' }) return ( <div> <h1>Admin Panel</h1> <p>Current role: {orgRole}</p> <nav> {canManageMembers && <a href="/admin/members">Manage Members</a>} {canWriteData && <a href="/admin/content">Content Management</a>} {canDeleteData && <a href="/admin/danger-zone">Danger Zone</a>} {canViewAnalytics && <a href="/admin/analytics">Analytics</a>} </nav> </div> ) }
typescript'use client' import { Protect, useOrganization, useAuth } from '@clerk/nextjs' export function AdminSection() { const { organization } = useOrganization() const { has } = useAuth() return ( <div> <h2>{organization?.name}</h2> {/* Declarative: Protect component with fallback */} <Protect role="org:admin" fallback={<p>You need admin access to view this section.</p>} > <DangerZone /> </Protect> {/* Permission-based rendering */} <Protect permission="org:data:write"> <EditForm /> </Protect> {/* Imperative: has() for conditional logic */} {has?.({ permission: 'org:analytics:read' }) && ( <AnalyticsDashboard /> )} </div> ) }
typescript// app/api/org/members/route.ts import { auth, clerkClient } from '@clerk/nextjs/server' export async function GET() { const { orgId, has } = await auth() if (!orgId) return Response.json({ error: 'No org selected' }, { status: 400 }) if (!has({ permission: 'org:sys_memberships:read' })) { return Response.json({ error: 'Forbidden' }, { status: 403 }) } const client = await clerkClient() const members = await client.organizations.getOrganizationMembershipList({ organizationId: orgId, }) return Response.json({ members: members.data.map(m => ({ userId: m.publicUserData?.userId, name: `${m.publicUserData?.firstName} ${m.publicUserData?.lastName}`, email: m.publicUserData?.identifier, role: m.role, joinedAt: m.createdAt, })), }) } export async function POST(req: Request) { const { orgId, userId, has } = await auth() if (!orgId || !has({ permission: 'org:sys_memberships:manage' })) { return Response.json({ error: 'Forbidden' }, { status: 403 }) } const { emailAddress, role } = await req.json() const client = await clerkClient() const invitation = await client.organizations.createOrganizationInvitation({ organizationId: orgId, emailAddress, role: role || 'org:member', inviterUserId: userId!, }) return Response.json({ invitation: { id: invitation.id, emailAddress, role } }) }
typescript// lib/org-roles.ts — manage roles and permissions via API (released Nov 2025) import { clerkClient } from '@clerk/nextjs/server' export async function createCustomRole(orgId: string) { const client = await clerkClient() // Create a custom permission await client.organizations.createOrganizationPermission({ organizationId: orgId, name: 'Manage reports', key: 'org:reports:manage', description: 'Create, edit, and delete reports', }) // Create a custom role with that permission await client.organizations.createOrganizationRole({ organizationId: orgId, name: 'Report Manager', key: 'org:report_manager', description: 'Can manage all reports', permissions: ['org:reports:manage', 'org:data:read'], }) } // Update a member's role export async function updateMemberRole( orgId: string, userId: string, newRole: string ) { const client = await clerkClient() const memberships = await client.organizations.getOrganizationMembershipList({ organizationId: orgId, }) const membership = memberships.data.find( m => m.publicUserData?.userId === userId ) if (!membership) throw new Error('User is not a member of this organization') await client.organizations.updateOrganizationMembership({ organizationId: orgId, userId, role: newRole, }) }
Configure in Clerk Dashboard > SSO Connections > Add SAML Connection:
https://<your-clerk-frontend-api>.clerk.accounts.dev/v1/saml/acshttps://<your-clerk-frontend-api>.clerk.accounts.dev/v1/saml/metadataemail, firstName, lastNametypescript// Enforce SSO for specific email domains // Clerk Dashboard > Organizations > Settings > "Verified domains" // Add your company domain (e.g., acme.com) // Users with @acme.com emails will be forced through SSO
| Error | Cause | Solution | |-------|-------|----------| | orgId is null | No active organization | Redirect to org selector, show <OrganizationSwitcher /> | | has() returns false | Role/permission not assigned | Check assignment in Dashboard > Organizations > Members | | Permission denied on middleware | User lacks required role | Verify route matcher maps to correct role | | SSO login fails | Misconfigured IdP metadata | Verify ACS URL and Entity ID in IdP settings | | Invitation fails | Email already a member | Check membership before inviting | | Custom role not visible | Created via API, not Dashboard | Roles created via API are org-scoped, not instance-wide |
WHERE org_id = :orgId)org_id, org_role, and org_permissions -- available in middleware without API callsorg:sys_* system permissions (sys_memberships:manage, sys_memberships:read, sys_domains:manage) for built-in org management actionsProceed to clerk-migration-deep-dive for auth provider migration.
User: Only org:billing can open the invoices page.
Skill: implements role check on the server and fails closed when claims missing.User: Produce a role × capability matrix for our Clerk org roles.
Skill: enumerates Clerk roles/permissions and maps them to app routes/actions.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 19,582 | 17,375 | -11% | 1 | 1 | 0% | 3,533 | 6,314 | +79% | 0 | 0 | — |
case-02 | fail→pass | 15,019 | 8,670 | -42% | 1 | 1 | 0% | 2,751 | 4,662 | +69% | 0 | 0 | — |
case-03 | fail→pass | 11,727 | 6,557 | -44% | 1 | 1 | 0% | 2,402 | 4,369 | +82% | 0 | 0 | — |
case-04 | fail→pass | 12,786 | 8,829 | -31% | 1 | 1 | 0% | 2,507 | 4,886 | +95% | 0 | 0 | — |
case-05 | fail→pass | 6,965 | 6,872 | -1% | 1 | 1 | 0% | 1,356 | 4,169 | +207% | 0 | 0 | — |
case-06 | pass→pass | 13,167 | 12,183 | -7% | 1 | 1 | 0% | 2,358 | 5,348 | +127% | 0 | 0 | — |
case-07 | fail→pass | 14,106 | 9,655 | -32% | 1 | 1 | 0% | 2,703 | 4,953 | +83% | 0 | 0 | — |
case-08 | pass→pass | 11,484 | 10,816 | -6% | 1 | 1 | 0% | 2,313 | 5,389 | +133% | 0 | 0 | — |
case-09 | pass→pass | 9,240 | 6,323 | -32% | 1 | 1 | 0% | 1,716 | 4,290 | +150% | 0 | 0 | — |
case-10 | pass→pass | 13,958 | 11,389 | -18% | 1 | 1 | 0% | 2,191 | 4,873 | +122% | 0 | 0 | — |
case-11 | pass→pass | 10,705 | 8,473 | -21% | 1 | 1 | 0% | 1,842 | 4,370 | +137% | 0 | 0 | — |
case-12 | fail→pass | 15,196 | 6,780 | -55% | 1 | 1 | 0% | 2,519 | 4,204 | +67% | 0 | 0 | — |
case-13 | pass→pass | 9,187 | 6,859 | -25% | 1 | 1 | 0% | 1,606 | 4,230 | +163% | 0 | 0 | — |
case-14 | fail→pass | 8,771 | 5,825 | -34% | 1 | 1 | 0% | 1,749 | 4,180 | +139% | 0 | 0 | — |
case-15 | pass→pass | 19,579 | 12,537 | -36% | 1 | 1 | 0% | 3,524 | 5,348 | +52% | 0 | 0 | — |
case-16 | fail→fail | 14,953 | 15,538 | +4% | 1 | 1 | 0% | 2,506 | 5,409 | +116% | 0 | 0 | — |
case-17 | pass→pass | 14,068 | 10,626 | -24% | 1 | 1 | 0% | 2,551 | 4,857 | +90% | 0 | 0 | — |
case-18 | pass→pass | 5,290 | 3,967 | -25% | 1 | 1 | 0% | 1,024 | 3,642 | +256% | 0 | 0 | — |
case-19 | pass→pass | 10,184 | 4,896 | -52% | 1 | 1 | 0% | 1,795 | 3,753 | +109% | 0 | 0 | — |
case-20 | fail→fail | 21,862 | 22,328 | +2% | 1 | 1 | 0% | 3,719 | 7,118 | +91% | 0 | 0 | — |
case-21 | fail→fail | 17,650 | 11,886 | -33% | 1 | 1 | 0% | 2,846 | 5,087 | +79% | 0 | 0 | — |
case-22 | fail→fail | 21,648 | 18,263 | -16% | 1 | 1 | 0% | 3,981 | 6,633 | +67% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +32 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.