Claude-code-plugins-plus-skills clerk-core-workflow-b
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/clerk-pack/skills/clerk-core-workflow-b" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-skills-clerk-core-workflow-b && rm -rf "$T"
manifest:
plugins/saas-packs/clerk-pack/skills/clerk-core-workflow-b/SKILL.mdsource content
Clerk Core Workflow B: Session & Middleware
Overview
Implement session management and route protection with Clerk middleware. Covers
clerkMiddleware() configuration, auth() patterns, custom session claims, JWT templates for external services, organization-scoped sessions, and session token v2.
Prerequisites
installed with ClerkProvider wrapping the app@clerk/nextjs- Next.js 14+ with App Router
- Sign-in/sign-up flows working (
completed)clerk-core-workflow-a
Instructions
Step 1: Configure clerkMiddleware with Route Matchers
// middleware.ts (project root) import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' const isPublicRoute = createRouteMatcher([ '/', '/sign-in(.*)', '/sign-up(.*)', '/api/webhooks(.*)', '/pricing', '/blog(.*)', ]) const isAdminRoute = createRouteMatcher(['/admin(.*)']) const isApiRoute = createRouteMatcher(['/api(.*)']) export default clerkMiddleware(async (auth, req) => { // Public routes: no auth required if (isPublicRoute(req)) return // Admin routes: require org:admin role if (isAdminRoute(req)) { await auth.protect({ role: 'org:admin' }) return } // All other routes: require authentication await auth.protect() }) 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)(.*)', ], }
Key behavior:
clerkMiddleware() does NOT protect any routes by default. You must explicitly call auth.protect() for routes that require authentication. This is a design decision to avoid over-blocking.
Step 2: Protect API Routes with auth()
// app/api/data/route.ts import { auth } from '@clerk/nextjs/server' export async function GET() { const { userId, orgId, has } = await auth() if (!userId) { return Response.json({ error: 'Unauthorized' }, { status: 401 }) } // Permission-based authorization if (!has({ permission: 'org:data:read' })) { return Response.json({ error: 'Forbidden' }, { status: 403 }) } const data = orgId ? await db.items.findMany({ where: { organizationId: orgId } }) : await db.items.findMany({ where: { ownerId: userId } }) return Response.json({ data, userId, orgId }) } export async function POST(req: Request) { const { userId, orgId, has } = await auth() if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 }) if (!has({ permission: 'org:data:write' })) { return Response.json({ error: 'Forbidden' }, { status: 403 }) } const body = await req.json() const item = await db.items.create({ data: { ...body, ownerId: userId, organizationId: orgId }, }) return Response.json({ item }, { status: 201 }) }
Step 3: Server Component Auth Patterns
// app/dashboard/page.tsx import { auth, currentUser } from '@clerk/nextjs/server' import { redirect } from 'next/navigation' export default async function DashboardPage() { const { userId, orgId, orgRole, has, sessionClaims } = await auth() if (!userId) redirect('/sign-in') // auth() is free (JWT parsing) — use for lightweight checks const isAdmin = has({ role: 'org:admin' }) // currentUser() costs a Backend API call — use only when you need full profile const user = await currentUser() return ( <div> <h1>Welcome, {user?.firstName}</h1> <p>Organization: {orgId || 'Personal account'}</p> <p>Role: {orgRole || 'N/A'}</p> {isAdmin && <a href="/admin">Admin Panel</a>} </div> ) }
Step 4: Custom Session Claims
Customize in Dashboard > Sessions > Customize session token:
{ "metadata": "{{user.public_metadata}}", "email": "{{user.primary_email_address}}" }
Then declare types and access in code:
// types/clerk.d.ts declare global { interface CustomJwtSessionClaims { metadata?: { role?: string plan?: string } email?: string } } export {}
// Access custom claims (no API call needed — embedded in JWT) import { auth } from '@clerk/nextjs/server' export async function GET() { const { sessionClaims } = await auth() const userPlan = sessionClaims?.metadata?.plan || 'free' const userEmail = sessionClaims?.email return Response.json({ plan: userPlan, email: userEmail }) }
Warning: Session token cookie limit is 4KB. Custom claims should be under 1.2KB. Store large data in your database, not in session claims.
Step 5: JWT Templates for External Services
// app/api/supabase-data/route.ts import { auth } from '@clerk/nextjs/server' import { createClient } from '@supabase/supabase-js' export async function GET() { const { userId, getToken } = await auth() if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 }) // Get a JWT with Supabase-compatible claims // Configure the template in Dashboard > JWT Templates const supabaseToken = await getToken({ template: 'supabase' }) const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { global: { headers: { Authorization: `Bearer ${supabaseToken}` } } } ) const { data } = await supabase.from('items').select('*') return Response.json({ data }) }
Configure JWT template in Dashboard > JWT Templates > New template:
{ "sub": "{{user.id}}", "email": "{{user.primary_email_address}}", "role": "authenticated", "aud": "authenticated" }
Step 6: Organization-Scoped Sessions
'use client' import { useOrganizationList, useOrganization, useAuth } from '@clerk/nextjs' export function OrgSwitcher() { const { organizationList, setActive, isLoaded } = useOrganizationList({ userMemberships: { infinite: true }, }) const { organization } = useOrganization() if (!isLoaded) return <div>Loading orgs...</div> return ( <div> <p>Active: {organization?.name || 'Personal account'}</p> <ul> {organizationList?.map(({ organization: org, membership }) => ( <li key={org.id}> <button onClick={() => setActive({ organization: org.id })}> {org.name} ({membership.role}) </button> </li> ))} <li> <button onClick={() => setActive({ organization: null })}> Personal account </button> </li> </ul> </div> ) }
Step 7: Server Action Permission Guards
'use server' import { auth } from '@clerk/nextjs/server' export async function deleteItem(itemId: string) { const { userId, orgId, has } = await auth() if (!userId) throw new Error('Unauthorized') if (!has({ permission: 'org:data:delete' })) { throw new Error('You do not have permission to delete items') } await db.items.delete({ where: { id: itemId, organizationId: orgId } }) return { success: true } } export async function updateOrgSettings(settings: Record<string, any>) { const { orgId, has } = await auth() if (!orgId) throw new Error('No organization selected') if (!has({ role: 'org:admin' })) { throw new Error('Only admins can update organization settings') } await db.orgSettings.upsert({ where: { orgId }, update: settings, create: { orgId, ...settings }, }) return { success: true } }
Error Handling
| Issue | Cause | Solution |
|---|---|---|
| Middleware redirect loop | Sign-in page not in | Add to public route matcher |
| 401 on API route | Token not forwarded | Include credentials in fetch or use server-side |
is null | No active organization | Prompt user with |
always false | Permission not assigned to role | Check Dashboard > Organizations > Roles |
| Session expired | Token TTL exceeded | Clerk auto-refreshes; if stuck, clear cookies |
| Middleware missing or wrong location | Ensure at project root |
Enterprise Considerations
- Session token v2 (default since April 2025) is more compact -- if your downstream services parse JWTs, verify they handle the new format
in middleware returns a 401/redirect before reaching your route handler -- this is more efficient than checkingauth.protect()
in every routeuserId- For permission-based access, prefer
overhas({ permission: '...' })
-- permissions decouple authorization from role nameshas({ role: '...' }) - JWT templates support custom
,iss
, andaud
claims for integrating with Hasura, Supabase, Convex, Neon, and other servicesexp - Organization switching changes the active session scope instantly -- no page reload needed
Resources
Next Steps
Proceed to
clerk-webhooks-events for webhook and event handling.