# Factory Auth Wiring Specialist

> Use when wiring auth into a new project, switching auth providers, or adding role/org features. Carries the factory's auth conventions — the provider decision matrix (Better Auth + orgs primary, Supabase + RLS for RLS-heavy cases, Clerk for consumer/SSO), the unified `requireAuth` / `requireRole` / `withOrgContext` wrapper interface, procedure tier stacking, OAuth callback safety (`safeNext`), JWT signature verification with fallback user-linking, admin-client bypass guardrails, role-conditional post-login redirects. Produces auth code that fits the house seam — provider is a swap point, not a leak.

- Skill: `nonlinear-xyz/factory-auth-wiring-specialist` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nonlinear-xyz/factory-auth-wiring-specialist`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nonlinear-xyz/factory-auth-wiring-specialist/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: nonlinear-xyz (https://skillmd.com/u/nonlinear-xyz)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nonlinear-xyz/factory-auth-wiring-specialist

---


Apply the **auth-wiring-specialist** workflow. Wire auth using the factory's conventions, not generic provider-specific boilerplate. Load the canonical `factory-auth` and `factory-security` skills through the host's skill capability when needed.

## How to think (in order)

1. **Which provider?** Apply the decision matrix:
   - **Better Auth + organization plugin** — default. B2B with team/org concept.
   - **Supabase Auth + RLS** — RLS is doing real work (multi-role partner/distributor, deeply branched authz).
   - **Clerk** — consumer / SSO-heavy. Managed UI matters.

   If the user has expressed a preference or the project already has a provider, defer to that. Otherwise pick from the matrix and flag.

2. **Wrapper interface present?** Check for `src/lib/auth/` (or equivalent). The seam is:
   - `requireAuth() -> { user, session }`
   - `requireRole(role) -> { user, session }`
   - `withOrgContext(fn)` — wraps an async function with org context

   If the seam exists, use it. If not, create it before writing any new auth-touching code.

3. **Procedure tiers?** For tRPC projects:
   - `publicProcedure` — anyone
   - `protectedProcedure = publicProcedure.use(requireAuth)`
   - `orgProcedure = protectedProcedure.use(requireOrg)`

   For server-action projects, the equivalent is calling the wrappers at the top of each action.

4. **Multi-tenancy enforcement?** Every domain query / mutation must:
   - Pull `orgId` from session via `withOrgContext`
   - Filter by `orgId` in the query / mutation
   - Never trust an `orgId` from the request body — always from the session

5. **OAuth flows?**
   - Validate `?next=` params with `safeNext()` — reject protocol-relative, non-relative URLs
   - Post-login redirect by role (admin → `/admin`, rep → `/submit`, default `/`)
   - Skip `?next=` for OAuth callback flows (too easy to weaponize on first sign-in)

6. **JWT verification?** (Clerk / Supabase)
   - Always verify RS256 signature against JWKS
   - Cache JWKS in memory; refresh on signature failure
   - Fallback user-linking on first request (create user record inline if webhook hasn't arrived)

7. **Admin / service-role client?**
   - Wrap in `withAdmin(fn)` — never expose at module scope
   - Call `requireAdmin()` inside the wrapper before returning the client
   - See `factory-security.md`

8. **Role definition?** Don't put roles in code as string literals. Define an enum / const and reference it:

   ```ts
   export const ROLES = ['owner', 'admin', 'member', 'guest'] as const;
   export type Role = (typeof ROLES)[number];
   ```

## Reference: canonical wrapper file

```ts
// src/lib/auth/index.ts
import { auth } from './provider';   // Better Auth / Supabase / Clerk import

export class AuthError extends Error {
  constructor(public reason: 'unauthenticated' | 'forbidden' | 'no_org_context') {
    super(reason);
  }
}

export async function requireAuth() {
  const session = await auth.getSession();
  if (!session) throw new AuthError('unauthenticated');
  return { user: session.user, session };
}

export async function requireRole(role: Role) {
  const ctx = await requireAuth();
  if (!ctx.user.roles.includes(role)) throw new AuthError('forbidden');
  return ctx;
}

export async function withOrgContext<T>(fn: (ctx: { orgId: string; user: User }) => Promise<T>): Promise<T> {
  const { user, session } = await requireAuth();
  const orgId = session.activeOrganizationId;
  if (!orgId) throw new AuthError('no_org_context');
  return fn({ orgId, user });
}

function safeNext(next: string | null): string {
  if (!next) return '/';
  if (next.startsWith('//')) return '/';
  if (!next.startsWith('/')) return '/';
  return next;
}
```

## Reference: Better Auth plugin composition

```ts
// src/lib/auth/provider.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { organization, admin, magicLink, twoFactor } from 'better-auth/plugins';
import { db } from '@/db';
import { customAC, roles } from './access-control';

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'pg' }),
  plugins: [
    organization({ accessControl: customAC, roles }),
    admin(),
    magicLink({ sendMagicLink: async (data) => /* ... */ }),
    twoFactor(),
  ],
});
```

## Output format

```
## Restated request
<one sentence>

## Provider decision
- Picked: <Better Auth / Supabase / Clerk>
- Reason: <which criterion>
- Existing in project: <yes/no — if yes, defer; if no, proposed>

## Wrapper interface
- Status: <exists / will create>
- Files: src/lib/auth/index.ts, src/lib/auth/provider.ts, src/lib/auth/access-control.ts

## Files to create or modify
<bulleted with paths>

## Code
<actual code, organized by file>

## Multi-tenancy check
- Org middleware: <wired>
- Domain queries filter by orgId: <yes/no — list any that don't>

## Security check
- safeNext on redirects: <yes>
- JWT signature verification: <yes — if Clerk/Supabase>
- Admin client wrapped: <yes>
- Hardcoded allowlists: <none / flagged>

## Open questions
<things the user should confirm>
```

## What you do NOT do

- **Don't write auth code inline in routes / actions.** Always through the wrapper interface.
- **Don't trust `orgId` from request body.** Always from session.
- **Don't expose the admin client at module scope.** Always wrap.
- **Don't stack three fallback auth paths** for the same surface. Pick one per surface.
- **Don't hardcode email allowlists in config.** DB-backed members table.
- **Don't decode JWTs without verifying signatures.**
- **Don't skip `safeNext` validation on redirect params.**
- **Don't 404 valid users when the webhook hasn't arrived.** Fallback user-linking.
- **Don't put roles as inline string literals.** Const / enum / type.

## When the request is too small for this framework

If the user asks to change a single role name or add one new field to the user table, do it directly. The framework is for wiring a new provider, swapping providers, or adding org/team features.

