authentication-authorization
Authentication and authorization patterns using Clerk and RBAC
Best use case
authentication-authorization is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Authentication and authorization patterns using Clerk and RBAC
Teams using authentication-authorization should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/authentication-authorization/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How authentication-authorization Compares
| Feature / Agent | authentication-authorization | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Authentication and authorization patterns using Clerk and RBAC
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Authentication & Authorization Skill
This skill provides guidance for implementing authentication and authorization in Splits Network.
## Purpose
Help developers implement secure, role-based access control:
- **Clerk Integration**: Authentication with Clerk
- **V2 Access Context**: Role-based data filtering
- **API Gateway RBAC**: Route-level authorization
- **Frontend Auth**: Protected routes and role checks
- **User Identification**: Proper ID handling across services
## When to Use This Skill
Use this skill when:
- Implementing protected API endpoints
- Creating repository methods with role-based filtering
- Building protected frontend pages
- Checking user permissions
- Debugging authorization issues
## Core Principles
### 1. V2 Access Context Pattern (Recommended)
**All V2 services** use shared access context for data-level authorization:
```typescript
// packages/shared-access-context/src/index.ts
import { SupabaseClient } from "@supabase/supabase-js";
export interface AccessContext {
userId: string; // Internal UUID (users.id)
clerkUserId: string; // Clerk user ID
role: UserRole; // Primary role
memberships: Membership[]; // Organization memberships
isCompanyUser: boolean; // Has company affiliation
isRecruiter: boolean; // Is recruiter (independent or affiliated)
accessibleCompanyIds: string[]; // Companies user can access
}
export async function resolveAccessContext(
clerkUserId: string,
supabase: SupabaseClient,
): Promise<AccessContext> {
// 1. Lookup internal user ID
const { data: user } = await supabase
.from("users")
.select("id")
.eq("clerk_user_id", clerkUserId)
.single();
if (!user) {
throw new Error("User not found");
}
// 2. Get memberships
const { data: memberships } = await supabase
.from("memberships")
.select("*, organizations(*)")
.eq("user_id", user.id);
// 3. Determine role and permissions
const isPlatformAdmin = memberships?.some(
(m) => m.role === "platform_admin",
);
const isCompanyAdmin = memberships?.some((m) => m.role === "company_admin");
const isHiringManager = memberships?.some(
(m) => m.role === "hiring_manager",
);
// 4. Check recruiter status
const { data: recruiter } = await supabase
.from("recruiters")
.select("id, status")
.eq("user_id", user.id)
.single();
const isRecruiter = !!recruiter && recruiter.status === "active";
// 5. Get accessible company IDs
const accessibleCompanyIds =
memberships?.map((m) => m.organization.id).filter(Boolean) || [];
return {
userId: user.id,
clerkUserId,
role: isPlatformAdmin
? "platform_admin"
: isCompanyAdmin
? "company_admin"
: isHiringManager
? "hiring_manager"
: isRecruiter
? "recruiter"
: "user",
memberships: memberships || [],
isCompanyUser: isCompanyAdmin || isHiringManager,
isRecruiter,
accessibleCompanyIds,
};
}
```
See [examples/access-context.ts](./examples/access-context.ts).
### 2. Repository with Access Context
Apply role-based filtering in repository queries:
```typescript
// services/ats-service/src/v2/jobs/repository.ts
import { resolveAccessContext } from "@splits-network/shared-access-context";
export class JobRepository {
constructor(private supabase: SupabaseClient) {}
async list(clerkUserId: string, filters: JobFilters): Promise<Job[]> {
const context = await resolveAccessContext(clerkUserId, this.supabase);
const query = this.supabase.from("jobs").select("*");
// Apply role-based filtering
if (context.role === "platform_admin") {
// Platform admins see all jobs (no filter)
} else if (context.isCompanyUser) {
// Company users see their organization's jobs
query.in("company_id", context.accessibleCompanyIds);
} else if (context.isRecruiter) {
// Recruiters see jobs they're assigned to
const { data: assignments } = await this.supabase
.from("role_assignments")
.select("job_id")
.eq("recruiter_user_id", context.userId);
query.in("id", assignments?.map((a) => a.job_id) || []);
} else {
// Regular users see no jobs
query.eq("id", "impossible"); // Force empty result
}
// Apply additional filters
if (filters.status) {
query.eq("status", filters.status);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
async getById(id: string, clerkUserId: string): Promise<Job | null> {
const context = await resolveAccessContext(clerkUserId, this.supabase);
const { data, error } = await this.supabase
.from("jobs")
.select("*")
.eq("id", id)
.single();
if (error || !data) return null;
// Check access
if (context.role === "platform_admin") {
return data; // Admins can see all
} else if (context.isCompanyUser) {
// Check company ownership
if (!context.accessibleCompanyIds.includes(data.company_id)) {
throw new ForbiddenError("Access denied to this job");
}
} else if (context.isRecruiter) {
// Check assignment
const { data: assignment } = await this.supabase
.from("role_assignments")
.select("id")
.eq("job_id", id)
.eq("recruiter_user_id", context.userId)
.single();
if (!assignment) {
throw new ForbiddenError("Access denied to this job");
}
} else {
throw new ForbiddenError("Access denied");
}
return data;
}
}
```
**Key Rules**:
- ✅ Always call `resolveAccessContext` at start of repository method
- ✅ Apply role-based filtering to ALL queries
- ✅ Platform admins see everything (no filter)
- ✅ Company users see their organization's data
- ✅ Recruiters see assigned data
- ✅ Regular users see minimal data (or none)
- ❌ Never skip access context checks
See [examples/repository-with-access-context.ts](./examples/repository-with-access-context.ts).
### 3. API Gateway RBAC (V1 Pattern - Legacy)
API Gateway enforces route-level authorization:
```typescript
// services/api-gateway/src/rbac.ts
import { FastifyRequest, FastifyReply } from "fastify";
export type UserRole =
| "platform_admin"
| "company_admin"
| "hiring_manager"
| "recruiter"
| "candidate"
| "user";
export function requireRoles(
allowedRoles: UserRole[],
services?: ServiceRegistry,
) {
return async (request: FastifyRequest, reply: FastifyReply) => {
const clerkUserId = request.auth?.clerkUserId;
if (!clerkUserId) {
return reply.code(401).send({
error: {
code: "UNAUTHORIZED",
message: "Authentication required",
},
});
}
// Check memberships first (fast path)
const memberships = request.auth.memberships || [];
// Platform admin always allowed
if (isPlatformAdmin(memberships)) {
return;
}
// Check company roles
if (
allowedRoles.includes("company_admin") &&
isCompanyAdmin(memberships)
) {
return;
}
if (
allowedRoles.includes("hiring_manager") &&
isHiringManager(memberships)
) {
return;
}
// Check recruiter status (requires network service call)
if (allowedRoles.includes("recruiter") && services) {
const isRecruiterActive = await isRecruiter(
memberships,
clerkUserId,
services.network,
);
if (isRecruiterActive) {
return;
}
}
// Check candidate status (requires ATS service call)
if (allowedRoles.includes("candidate") && services) {
const { data: candidates } = await services.ats.get(
`/api/v2/candidates?limit=1`,
{ headers: { "x-clerk-user-id": clerkUserId } },
);
if (candidates?.data?.length > 0) {
return;
}
}
// No matching roles
return reply.code(403).send({
error: {
code: "FORBIDDEN",
message: "You do not have permission to access this resource",
},
});
};
}
// Helper functions
export function isPlatformAdmin(memberships: Membership[]): boolean {
return memberships.some((m) => m.role === "platform_admin");
}
export function isCompanyAdmin(
memberships: Membership[],
orgId?: string,
): boolean {
return memberships.some(
(m) =>
m.role === "company_admin" &&
(!orgId || m.organization_id === orgId),
);
}
export async function isRecruiter(
memberships: Membership[],
userId: string,
networkService: NetworkServiceClient,
): Promise<boolean> {
// Check memberships first
if (memberships.some((m) => m.role === "recruiter")) {
return true;
}
// Check independent recruiter status
try {
const recruiter = await networkService.getRecruiterByUserId(userId);
return recruiter?.status === "active";
} catch (error) {
return false;
}
}
```
**Gateway RBAC Rules**:
- ✅ Gateway enforces route-level authorization
- ✅ Backend services apply data-level filtering
- ✅ Always pass `services` parameter when allowing recruiters/candidates
- ✅ Check memberships first (fast), then external services
- ❌ Backend services should NOT duplicate authorization checks
See [examples/gateway-rbac.ts](./examples/gateway-rbac.ts).
### 4. Protected API Routes
Use `requireRoles` middleware on protected endpoints:
```typescript
// services/api-gateway/src/routes/jobs/routes.ts
import { requireRoles } from "../../rbac";
export async function jobsRoutes(
app: FastifyInstance,
services: ServiceRegistry,
) {
// Public endpoint - no auth required
app.get("/api/v2/jobs/public", async (request, reply) => {
// Return public job listings
});
// Protected endpoint - requires recruiter or company user
app.get(
"/api/v2/jobs",
{
preHandler: requireRoles(
[
"recruiter",
"company_admin",
"hiring_manager",
"platform_admin",
],
services,
),
},
async (request, reply) => {
const clerkUserId = request.auth.clerkUserId;
// Forward to ATS service with auth headers
const response = await services.ats.get("/api/v2/jobs", {
headers: buildAuthHeaders(request),
});
return reply.send(response.data);
},
);
// Admin-only endpoint
app.delete(
"/api/v2/jobs/:id",
{
preHandler: requireRoles(["platform_admin"]),
},
async (request, reply) => {
// Only platform admins can delete
},
);
}
```
**Route Protection Rules**:
- ✅ Use `requireRoles` on all protected endpoints
- ✅ Pass `services` when allowing recruiters/candidates
- ✅ Forward auth headers to backend services
- ⚠️ Use minimum required roles (don't over-restrict)
See [examples/protected-routes.ts](./examples/protected-routes.ts).
### 5. Frontend Protected Routes
Protect Next.js pages with Clerk:
```typescript
// apps/portal/src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublicRoute = createRouteMatcher([
"/sign-in(.*)",
"/sign-up(.*)",
"/",
"/about",
]);
export default clerkMiddleware((auth, req) => {
if (!isPublicRoute(req)) {
auth().protect(); // Require authentication
}
});
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
```
```typescript
// apps/portal/app/portal/jobs/page.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export default async function JobsPage() {
const { userId } = await auth();
if (!userId) {
redirect("/sign-in");
}
// Page content...
}
```
**Frontend Auth Rules**:
- ✅ Use Clerk middleware for route protection
- ✅ Check auth in page components
- ✅ Redirect unauthenticated users to sign-in
- ✅ Show loading states during auth checks
See [examples/protected-frontend-routes.tsx](./examples/protected-frontend-routes.tsx).
### 6. Role-Based UI
Show/hide UI based on user role:
```typescript
'use client';
import { useUser } from '@clerk/nextjs';
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
export function RoleBasedActions() {
const { user } = useUser();
const [userRole, setUserRole] = useState<string | null>(null);
useEffect(() => {
async function fetchRole() {
const { data } = await apiClient.get('/users/me/role');
setUserRole(data.role);
}
if (user) {
fetchRole();
}
}, [user]);
if (!userRole) return null;
return (
<div className="flex gap-2">
{/* All authenticated users */}
<button className="btn">View Jobs</button>
{/* Recruiters only */}
{(userRole === 'recruiter' || userRole === 'platform_admin') && (
<button className="btn btn-primary">Submit Candidate</button>
)}
{/* Company admins only */}
{(userRole === 'company_admin' || userRole === 'platform_admin') && (
<button className="btn btn-primary">Create Job</button>
)}
{/* Platform admins only */}
{userRole === 'platform_admin' && (
<button className="btn btn-error">Delete</button>
)}
</div>
);
}
```
**UI Role Rules**:
- ✅ Fetch user role from API
- ✅ Show/hide actions based on role
- ✅ Always enforce permissions on backend (UI is not security)
- ⚠️ Handle loading states gracefully
See [examples/role-based-ui.tsx](./examples/role-based-ui.tsx).
### 7. User Identification Standards
**Critical**: Proper user ID handling across the stack:
```typescript
// ❌ WRONG - Frontend setting user ID header
fetch("/api/jobs", {
headers: {
"x-clerk-user-id": userId, // NEVER do this!
},
});
// ✅ CORRECT - Frontend sends only auth token
fetch("/api/jobs", {
headers: {
Authorization: `Bearer ${token}`, // Clerk JWT
},
});
// ✅ API Gateway extracts user ID from verified JWT
app.addHook("onRequest", async (request, reply) => {
const token = request.headers.authorization?.replace("Bearer ", "");
const verified = await verifyClerkJWT(token);
request.auth = {
clerkUserId: verified.sub, // ← From verified token, not header
userId: verified.userId,
};
});
// ✅ Gateway forwards to backend services
const response = await services.ats.get("/api/v2/jobs", {
headers: {
"x-clerk-user-id": request.auth.clerkUserId, // ← From verified JWT
},
});
// ✅ Backend service uses header (trusts gateway)
export async function jobsRoutes(app: FastifyInstance) {
app.get("/api/v2/jobs", async (request, reply) => {
const clerkUserId = request.headers["x-clerk-user-id"] as string;
const jobs = await repository.list(clerkUserId, filters);
return reply.send({ data: jobs });
});
}
```
**User ID Flow**:
1. Frontend: Send `Authorization: Bearer <token>` (Clerk JWT)
2. Gateway: Extract `clerkUserId` from verified JWT
3. Gateway: Forward `x-clerk-user-id: <clerkUserId>` to services
4. Service: Use `x-clerk-user-id` header (trust gateway)
See [references/user-identification-flow.md](./references/user-identification-flow.md).
## Role Hierarchy
```
platform_admin (highest)
↓
company_admin
↓
hiring_manager
↓
recruiter
↓
candidate
↓
user (lowest)
```
**Role Capabilities**:
- **platform_admin**: Full system access, manage all organizations
- **company_admin**: Manage organization, create jobs, view all org data
- **hiring_manager**: Create jobs, view applications for org jobs
- **recruiter**: Submit candidates, view assigned jobs
- **candidate**: View own profile, manage applications
- **user**: Authenticated user with no special permissions
See [references/role-capabilities.md](./references/role-capabilities.md).
## Testing Authorization
Test access control:
```typescript
describe("JobRepository", () => {
it("should filter jobs by role", async () => {
// Mock access context for recruiter
vi.mocked(resolveAccessContext).mockResolvedValue({
userId: "user_123",
clerkUserId: "clerk_123",
role: "recruiter",
isRecruiter: true,
accessibleCompanyIds: [],
});
const jobs = await repository.list("clerk_123", {});
// Should only see assigned jobs
expect(jobs).toHaveLength(2);
});
it("should throw ForbiddenError for unauthorized access", async () => {
vi.mocked(resolveAccessContext).mockResolvedValue({
userId: "user_123",
role: "user",
isRecruiter: false,
});
await expect(
repository.getById("job_456", "clerk_123"),
).rejects.toThrow(ForbiddenError);
});
});
describe("API Gateway", () => {
it("should require authentication", async () => {
const response = await app.inject({
method: "GET",
url: "/api/v2/jobs",
// No auth headers
});
expect(response.statusCode).toBe(401);
});
it("should enforce role-based access", async () => {
const response = await app.inject({
method: "DELETE",
url: "/api/v2/jobs/123",
headers: {
Authorization: "Bearer recruiter_token", // Not admin
},
});
expect(response.statusCode).toBe(403);
});
});
```
See [examples/authorization-testing.ts](./examples/authorization-testing.ts).
## Anti-Patterns to Avoid
### ❌ Client-Side Only Auth
```typescript
// WRONG - No backend verification
if (user.role === "admin") {
await deleteJob(id); // Anyone can call this!
}
// CORRECT - Backend enforces auth
await apiClient.delete(`/jobs/${id}`);
// Backend checks role and throws 403 if not authorized
```
### ❌ Skipping Access Context
```typescript
// WRONG - No role filtering
async list(): Promise<Job[]> {
return await this.supabase.from('jobs').select('*');
}
// CORRECT - Role-based filtering
async list(clerkUserId: string): Promise<Job[]> {
const context = await resolveAccessContext(clerkUserId, this.supabase);
// Apply role-based filtering...
}
```
### ❌ Hardcoding Permissions
```typescript
// WRONG - Hardcoded user IDs
if (userId === "specific-user-id") {
// Grant special access
}
// CORRECT - Role-based
if (context.role === "platform_admin") {
// Grant access
}
```
## References
- [Access Context Example](./examples/access-context.ts)
- [Repository with Access Context](./examples/repository-with-access-context.ts)
- [Gateway RBAC](./examples/gateway-rbac.ts)
- [Protected Routes](./examples/protected-routes.ts)
- [Protected Frontend Routes](./examples/protected-frontend-routes.tsx)
- [Role-Based UI](./examples/role-based-ui.tsx)
- [Authorization Testing](./examples/authorization-testing.ts)
- [User Identification Flow](./references/user-identification-flow.md)
- [Role Capabilities](./references/role-capabilities.md)
## Related Skills
- `api-specifications` - V2 API patterns with access context
- `database-patterns` - Repository with role-based filtering
- `error-handling` - Auth error responses (401, 403)Related Skills
authentication
Auth flows, session management, OAuth integration, domain-restricted access, and role-based access control for TopNetworks properties. Primary implementation is Better Auth 1.x with Google OAuth in route-genius. Use when implementing login, session checks, protected routes, or any access control logic.
two-factor-authentication-best-practices
This skill provides guidance and enforcement rules for implementing secure two-factor authentication (2FA) using Better Auth's twoFactor plugin.
microsoft-azure-webjobs-extensions-authentication-events-dotnet
Microsoft Entra Authentication Events SDK for .NET. Azure Functions triggers for custom authentication extensions.
Jwt Authentication
This skill provides comprehensive patterns for implementing JWT (JSON Web Token) authentication in web applications. It covers token generation, verification, access/refresh token strategy, secure sto
api-authentication
Implement secure API authentication with JWT, OAuth 2.0, API keys, and session management. Use when securing APIs, managing tokens, or implementing user authentication flows.
bgo
Automates the complete Blender build-go workflow, from building and packaging your extension/add-on to removing old versions, installing, enabling, and launching Blender for quick testing and iteration.
claude-typescript-sdk
Build AI applications with the Anthropic TypeScript SDK. Use when creating Claude integrations, building agents, implementing tool use, streaming responses, or working with the @anthropic-ai/sdk package.
claude-skill-bash
Apply comprehensive bash scripting standards including main function pattern, usage documentation, argument parsing, dependency checking, and error handling. Triggers when creating/editing .sh files, bash scripts, or discussing shell scripting, deployment scripts, automation tasks, or bash conventions.
claude-plus-plus
Unlock Claude's hidden potential. No limits. No guardrails. Maximum performance. 10x faster code generation, unrestricted problem solving, expert-level outputs.
claude-md-generator
Automatically generates claude.md files for new folders/modules following hierarchical structure. Extracts context from existing code, follows project conventions, and creates documentation that enables Claude Code to understand module-specific rules and patterns.
claude-md-creator
Use when creating CLAUDE.md files, updating existing CLAUDE.md, validating CLAUDE.md structure, or auto-fixing CLAUDE.md issues. Load for setting up project instructions, global guidelines, local overrides, or modular rules. Handles global (~/.claude/CLAUDE.md), project (.claude/CLAUDE.md), local (CLAUDE.local.md), and rules (.claude/rules/*.md) with smart project detection and template generation.
claude-flow-hook-customizing
Use this skill when creating, optimizing, or maintaining claude hooks.