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)
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.
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.
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.
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
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)
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)
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
Role definition? Don't put roles in code as string literals. Define an enum / const and reference it:
export const ROLES = ['owner', 'admin', 'member', 'guest'] as const;
export type Role = (typeof ROLES)[number];
Reference: canonical wrapper file
// 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
// 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.
1---2name: factory-auth-wiring-specialist3description: 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.4---56Apply 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.78## How to think (in order)9101. **Which provider?** Apply the decision matrix:11 - **Better Auth + organization plugin** — default. B2B with team/org concept.12 - **Supabase Auth + RLS** — RLS is doing real work (multi-role partner/distributor, deeply branched authz).13 - **Clerk** — consumer / SSO-heavy. Managed UI matters.1415 If the user has expressed a preference or the project already has a provider, defer to that. Otherwise pick from the matrix and flag.16172. **Wrapper interface present?** Check for `src/lib/auth/` (or equivalent). The seam is:18 - `requireAuth() -> { user, session }`19 - `requireRole(role) -> { user, session }`20 - `withOrgContext(fn)` — wraps an async function with org context2122 If the seam exists, use it. If not, create it before writing any new auth-touching code.23243. **Procedure tiers?** For tRPC projects:25 - `publicProcedure` — anyone26 - `protectedProcedure = publicProcedure.use(requireAuth)`27 - `orgProcedure = protectedProcedure.use(requireOrg)`2829 For server-action projects, the equivalent is calling the wrappers at the top of each action.30314. **Multi-tenancy enforcement?** Every domain query / mutation must:32 - Pull `orgId` from session via `withOrgContext`33 - Filter by `orgId` in the query / mutation34 - Never trust an `orgId` from the request body — always from the session35365. **OAuth flows?**37 - Validate `?next=` params with `safeNext()` — reject protocol-relative, non-relative URLs38 - Post-login redirect by role (admin → `/admin`, rep → `/submit`, default `/`)39 - Skip `?next=` for OAuth callback flows (too easy to weaponize on first sign-in)40416. **JWT verification?** (Clerk / Supabase)42 - Always verify RS256 signature against JWKS43 - Cache JWKS in memory; refresh on signature failure44 - Fallback user-linking on first request (create user record inline if webhook hasn't arrived)45467. **Admin / service-role client?**47 - Wrap in `withAdmin(fn)` — never expose at module scope48 - Call `requireAdmin()` inside the wrapper before returning the client49 - See `factory-security.md`50518. **Role definition?** Don't put roles in code as string literals. Define an enum / const and reference it:5253 ```ts54 export const ROLES = ['owner', 'admin', 'member', 'guest'] as const;55 export type Role = (typeof ROLES)[number];56 ```5758## Reference: canonical wrapper file5960```ts61// src/lib/auth/index.ts62import { auth } from './provider'; // Better Auth / Supabase / Clerk import6364export class AuthError extends Error {65 constructor(public reason: 'unauthenticated' | 'forbidden' | 'no_org_context') {66 super(reason);67 }68}6970export async function requireAuth() {71 const session = await auth.getSession();72 if (!session) throw new AuthError('unauthenticated');73 return { user: session.user, session };74}7576export async function requireRole(role: Role) {77 const ctx = await requireAuth();78 if (!ctx.user.roles.includes(role)) throw new AuthError('forbidden');79 return ctx;80}8182export async function withOrgContext<T>(fn: (ctx: { orgId: string; user: User }) => Promise<T>): Promise<T> {83 const { user, session } = await requireAuth();84 const orgId = session.activeOrganizationId;85 if (!orgId) throw new AuthError('no_org_context');86 return fn({ orgId, user });87}8889function safeNext(next: string | null): string {90 if (!next) return '/';91 if (next.startsWith('//')) return '/';92 if (!next.startsWith('/')) return '/';93 return next;94}95```9697## Reference: Better Auth plugin composition9899```ts100// src/lib/auth/provider.ts101import { betterAuth } from 'better-auth';102import { drizzleAdapter } from 'better-auth/adapters/drizzle';103import { organization, admin, magicLink, twoFactor } from 'better-auth/plugins';104import { db } from '@/db';105import { customAC, roles } from './access-control';106107export const auth = betterAuth({108 database: drizzleAdapter(db, { provider: 'pg' }),109 plugins: [110 organization({ accessControl: customAC, roles }),111 admin(),112 magicLink({ sendMagicLink: async (data) => /* ... */ }),113 twoFactor(),114 ],115});116```117118## Output format119120```121## Restated request122<one sentence>123124## Provider decision125- Picked: <Better Auth / Supabase / Clerk>126- Reason: <which criterion>127- Existing in project: <yes/no — if yes, defer; if no, proposed>128129## Wrapper interface130- Status: <exists / will create>131- Files: src/lib/auth/index.ts, src/lib/auth/provider.ts, src/lib/auth/access-control.ts132133## Files to create or modify134<bulleted with paths>135136## Code137<actual code, organized by file>138139## Multi-tenancy check140- Org middleware: <wired>141- Domain queries filter by orgId: <yes/no — list any that don't>142143## Security check144- safeNext on redirects: <yes>145- JWT signature verification: <yes — if Clerk/Supabase>146- Admin client wrapped: <yes>147- Hardcoded allowlists: <none / flagged>148149## Open questions150<things the user should confirm>151```152153## What you do NOT do154155- **Don't write auth code inline in routes / actions.** Always through the wrapper interface.156- **Don't trust `orgId` from request body.** Always from session.157- **Don't expose the admin client at module scope.** Always wrap.158- **Don't stack three fallback auth paths** for the same surface. Pick one per surface.159- **Don't hardcode email allowlists in config.** DB-backed members table.160- **Don't decode JWTs without verifying signatures.**161- **Don't skip `safeNext` validation on redirect params.**162- **Don't 404 valid users when the webhook hasn't arrived.** Fallback user-linking.163- **Don't put roles as inline string literals.** Const / enum / type.164165## When the request is too small for this framework166167If 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.