Auth Providers skill for Claude Code
Hosted login wired in, authorization kept server-side. Claude picks an auth provider from your real constraints (self-hosting, SSO, MAU, data residency), wires the current SDK for your framework version, and refuses to treat middleware as authorization: every data read re-checks the session. Users are keyed on stable provider IDs and synced through signature-verified webhooks, with a tested plan for moving between providers.
- Name
auth-providers- Size
- 231 lines · ≈2,550 tokens
- Files
- 1
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Integrates hosted and library auth providers (Clerk, Auth0, Auth.js, Better Auth) into web apps, covering sign-in setup, session reads on server and client, middleware or proxy route protection, roles and organizations, user webhooks synced to the database, and provider migrations. Use when adding login or social sign-in to Next.js or similar apps, protecting routes, mapping users to app records, choosing between providers, or moving off one. Try asking
- Add Clerk sign-in to this Next.js app and protect the dashboard routes
- Should we use Better Auth or Auth0 for our B2B SaaS with SAML customers?
- Migrate our NextAuth users to Better Auth without forcing password resets
Use it when
- Adding sign-in, social login, or organizations to a web app
- Choosing between hosted and self-hosted auth libraries
- Syncing provider users into your own database
- Upgrading or migrating between Clerk, Auth0, Auth.js, and Better Auth
Skip it when
- You are implementing OAuth or OIDC flows by hand — use oauth-oidc instead
- You are designing password storage, cookies, and sessions yourself — use auth-sessions instead
How to check the result
Signed-out requests to protected APIs return 401, cross-tenant object URLs return 404 or 403, and the user webhook rejects a bad signature while upserting a real test event.
The common mistake it prevents
Relying on middleware alone to block access, so a server action or route handler that skips the session check serves another tenant's data.
Done well means: the provider owns credentials, your database owns app data keyed by a stable provider user ID, every protected read is authorized on the server next to the data (not only in middleware), and secrets come from env vars per environment.
Ground rules
- Middleware/proxy checks are a routing convenience, not authorization. Re-check the session
in every server action, route handler, and data-access function. (Next.js middleware was
bypassable via
x-middleware-subrequestin CVE-2025-29927; patched, but the lesson stands.) - Key app records on the provider’s immutable user ID (
user_...,auth0|...), never on email. Emails change and can be unverified. - Never trust role or org claims sent from the client. Read them from the verified session or the provider’s backend API.
- Secrets (
CLERK_SECRET_KEY,AUTH0_CLIENT_SECRET,AUTH_SECRET,BETTER_AUTH_SECRET) differ per environment and never ship to the browser. Only publishable keys are public. - Callback/redirect URLs must be allowlisted exactly per environment, including preview URLs.
Choose the provider
| Situation | Default | Notes |
|---|---|---|
| Ship fast, want prebuilt UI, orgs, MFA, user management dashboard | Clerk | Per-MAU pricing; data lives with Clerk |
| Enterprise SSO (SAML, many IdPs), B2B tenants, compliance asks | Auth0 (or WorkOS for SSO-only) | Strong enterprise features; configuration-heavy |
| Self-hosted, users in your own Postgres/MySQL/SQLite, TypeScript | Better Auth | Library, plugins for orgs/2FA/passkeys; you run it |
| Existing Auth.js / NextAuth app | Keep Auth.js; plan migration only with a reason | Auth.js is now maintained by the Better Auth team, which recommends Better Auth for new projects |
| Mobile + web with Supabase/Firebase backend | That platform’s auth | Avoid a second identity system |
Ask the user about: self-host vs. hosted, SSO needs, expected MAU, and where user data must reside. Do not pick silently when these are unknown.
Workflow
- [ ] 1. Inventory current auth: library, session type, user table, protected routes
- [ ] 2. Choose provider (table above) and confirm with the user
- [ ] 3. Configure env vars and callback URLs per environment
- [ ] 4. Mount the handler/provider and sign-in UI
- [ ] 5. Protect routes in middleware/proxy AND authorize in the data layer
- [ ] 6. Sync users to the app DB (webhook or on first request)
- [ ] 7. Add roles/orgs if needed
- [ ] 8. Verify: signed-out, signed-in, wrong-role, expired-session paths
1. Inventory
grep -E '"(@clerk/[a-z-]+|@auth0/[a-z-]+|next-auth|@auth/[a-z-]+|better-auth)"' package.json
ls middleware.ts proxy.ts src/middleware.ts src/proxy.ts 2>/dev/null
grep -rn "getServerSession\|auth()\|currentUser\|getSession" --include=*.ts* src app 2>/dev/null | head -40
Next.js 16 renamed middleware.ts to proxy.ts (export proxy); earlier versions use
middleware.ts. Match the project’s Next.js version.
Provider recipes (Next.js App Router)
Clerk (@clerk/nextjs)
// middleware.ts (or proxy.ts on Next.js 16)
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtected = createRouteMatcher(["/dashboard(.*)", "/api/private(.*)"]);
export default clerkMiddleware(async (auth, req) => {
if (isProtected(req)) 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)(.*)"],
};
// Server code: auth() is async in current versions
import { auth, currentUser } from "@clerk/nextjs/server";
const { userId, orgId, has } = await auth();
if (!userId) throw new Response("Unauthorized", { status: 401 });
if (!has({ role: "org:admin" })) throw new Response("Forbidden", { status: 403 });
Wrap the root layout in <ClerkProvider>. Env: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
CLERK_SECRET_KEY, CLERK_WEBHOOK_SIGNING_SECRET.
User sync webhook (Clerk signs with Svix):
// app/api/webhooks/clerk/route.ts
import { verifyWebhook } from "@clerk/nextjs/webhooks";
export async function POST(req: Request) {
try {
const evt = await verifyWebhook(req); // reads CLERK_WEBHOOK_SIGNING_SECRET
if (evt.type === "user.created" || evt.type === "user.updated") await upsertUser(evt.data);
if (evt.type === "user.deleted") await softDeleteUser(evt.data.id);
return new Response("ok");
} catch {
return new Response("Invalid signature", { status: 400 });
}
}
If the installed version lacks verifyWebhook, verify with the svix package
(new Webhook(secret).verify(rawBody, { "svix-id", "svix-timestamp", "svix-signature" })).
Make the webhook route public in the middleware matcher.
Auth0 (@auth0/nextjs-auth0 v4)
// lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client();
// middleware.ts
import type { NextRequest } from "next/server";
import { auth0 } from "./lib/auth0";
export async function middleware(request: NextRequest) { return await auth0.middleware(request); }
v4 mounts routes at /auth/login, /auth/logout, /auth/callback (v3 used /api/auth/*;
update links and Allowed Callback URLs when upgrading). Read sessions with
await auth0.getSession(). Env: AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET,
AUTH0_SECRET (openssl rand -hex 32), APP_BASE_URL. Put roles into tokens with a
Post-Login Action using a namespaced custom claim (e.g. https://app.example.com/roles).
Auth.js (next-auth v5)
// auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [GitHub] });
// app/api/auth/[...nextauth]/route.ts
export const { GET, POST } = handlers;
Env: AUTH_SECRET (npx auth secret), AUTH_GITHUB_ID, AUTH_GITHUB_SECRET. Use an
adapter plus session: { strategy: "database" } when sessions must be revocable; JWT sessions
cannot be revoked before expiry.
Better Auth (better-auth)
// lib/auth.ts
import { betterAuth } from "better-auth";
export const auth = betterAuth({
database: pool, // pg Pool, Kysely, or Drizzle/Prisma adapter
emailAndPassword: { enabled: true },
socialProviders: {
github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET! },
},
});
// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
Generate/apply schema with npx @better-auth/cli generate (or migrate for the built-in
Kysely adapter). Server session: await auth.api.getSession({ headers: await headers() }).
Client: createAuthClient() from better-auth/react. Env: BETTER_AUTH_SECRET,
BETTER_AUTH_URL. Add organization, twoFactor, passkey plugins only when needed.
Authorize in the data layer
// lib/dal.ts — every data read goes through functions like this
export async function getProject(projectId: string) {
const user = await requireUser(); // provider-specific session read
const project = await db.project.findFirst({ where: { id: projectId, orgId: user.orgId } });
if (!project) throw notFound(); // same response for missing and forbidden
return project;
}
Roles and organizations
| Need | Approach |
|---|---|
| 2–3 global roles | Provider metadata/claims (Clerk publicMetadata, Auth0 Action claim), checked server-side |
| Multi-tenant B2B | Provider orgs (Clerk Organizations, Auth0 Organizations, Better Auth organization plugin) |
| Fine-grained per-resource permissions | Keep in your DB; the provider only proves identity |
Claims in session tokens are stale until refresh; for revocation-sensitive permissions read the DB.
Migration between providers
- Export users with provider IDs, emails, verified flags, and password hashes if the source allows it (Auth0 bulk export needs a support request for hashes; Clerk and Better Auth can import bcrypt hashes).
- Add a
legacy_user_idcolumn; map new IDs to existing app rows by that, not email. - If hashes cannot move, use lazy migration (verify against old system on first login) or a forced password reset email.
- Run both providers behind a flag; cut over; keep the old one read-only for rollback. Confirm with the user before deleting users or disabling the old provider.
Gotchas
- Webhook route left behind auth middleware: every delivery gets a 401/redirect.
- Assuming the user row exists on first request when sync is webhook-only: webhooks lag; upsert on first authenticated request as a fallback.
- Calling
auth()withoutawaiton Clerk v6+ / Next.js 15+: you get a Promise,userIdis undefined, and code treats everyone as signed out (or worse, skips the check). - Caching personalized pages (
force-static, CDN caching) after reading the session. - Different
AUTH_SECRETacross instances: sessions randomly invalid. - Preview deployments failing OAuth because their URLs are not in the provider’s allowlist.
Verification
- Signed out: protected page redirects to sign-in; protected API returns 401 (not 200 with empty data).
- Signed in as a user from another org: direct object URLs return 404/403.
- Role downgrade takes effect within the documented token lifetime.
- Webhook endpoint returns 400 for a bad signature and upserts on a real test event (send one from the provider dashboard).
grep -rn "SECRET" app src --include=*.tsxfinds no secret referenced in client components.
Output contract
**Provider:** Clerk (reason: hosted UI + orgs, ~5k MAU)
**Protected:** /dashboard/*, /api/private/* (proxy) + DAL checks in lib/dal.ts
**User sync:** user.created/updated/deleted webhook → users table (provider_user_id unique)
**Env vars:** NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, CLERK_WEBHOOK_SIGNING_SECRET
**Verified:** signed-out, cross-org, role checks, webhook signature rejection
**Follow-ups:** add preview domain to allowed origins About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.