Identity
Clerk · Postgres mirror · Orgs + RBAC
Identity is the load-bearing wall of every other pillar: tenancy needs an org id, billing needs a customer, audit logs need an actor. Pick a hosted auth provider (Clerk is the default in 2026 — the DX is unmatched) and mirror users, orgs and memberships into your own Postgres via webhooks so the rest of the app can join on them without an external call on every request. Keep the auth provider as the source of truth for credentials and sessions, and your DB as the source of truth for everything else.
Primary stack
The default picks this pillar assumes
- ClerkFree up to 10K MAU, then $0…
- Clerk Roles
Alternates worth knowing
Swap in when scale, cost or constraints change
- Supabase AuthFree up to 50K MAU, then ~$…
- WorkOSAuthKit free to 1M MAU; SSO…
- Cerbos / Oso
Auth Provider
The hosted service that owns credentials, sessions and the sign-in/up UI. Everything else — your DB, your APIs, your mobile app — trusts a JWT it issues. Choosing this once correctly is worth more than any other decision in this pillar.
How it flows
step by step- 01step
User opens /sign-in → Clerk's hosted component handles email/OAuth/passkeys.
- 02step
On success Clerk issues a session JWT (short-lived) + a refresh token (httpOnly cookie).
- 03step
Next.js middleware (clerkMiddleware) validates the JWT on every request, hydrates auth() on the server.
- 04step
Webhooks from Clerk (user.created, organization.created, organizationMembership.created) keep your Postgres mirror in sync.
- 05step
Your code never sees a password — only auth().userId and auth().orgId.
Cases & edge cases
watch for theseSwitching providers later is expensive — most teams underestimate the cost. Pick by scale + enterprise need, not by 'feels fast on the landing page'.
Pre-built UI is a feature, not a limitation. Resist building your own sign-in form until you have a concrete reason.
Don't store password hashes if you can avoid it — every byte of credential data on your side is liability.
Vendors & tools
what implements thisHosted auth + pre-built UI + orgs + roles + SSO/SCIM on the enterprise plan.
pricingFree up to 10K MAU, then $0.02/MAU; enterprise add-ons $100+/mo each
Pros
- Best-in-class DX — drop-in <SignIn/>, useUser(), auth() on the server
- Orgs + memberships built-in (huge for multi-tenant B2B)
- SAML SSO + SCIM on Pro plan, no separate vendor
Cons
- MAU pricing gets expensive past ~50K MAU
- Vendor lock-in on the org model (your code learns Clerk's shape)
Postgres-native auth — JWTs that RLS policies can read directly.
pricingFree up to 50K MAU, then ~$0.00325/MAU
Pros
- Cheapest at scale
- RLS integration is genuinely architectural, not bolted on
Cons
- UI is more DIY
- Orgs/teams need to be modeled by you
- No SCIM out of the box
Enterprise SSO + SCIM as a service — bolt on when Clerk's enterprise tier doesn't fit.
pricingAuthKit free to 1M MAU; SSO connections $125/mo each ($50 at 200+)
Pros
- Most generous free tier
- Enterprise-grade SSO/SCIM, audit logs, admin portal
Cons
- Not a full auth surface (no consumer flows out of the box)
Decisions
pick once, live with itDecision 01
Hosted (Clerk/Auth0/WorkOS) vs. integrated (Supabase Auth, Better Auth)?
pickHosted Clerk for new B2B SaaS; Supabase Auth when the DB is already Supabase.
whyDX + enterprise features (SSO/SCIM) ship in days, not quarters. The premium is small until you hit ~50K MAU, by which point you have revenue.
altsBetter Auth (self-hosted, modern, but SAML not yet GA)
Checklist
ship readiness- must
Provider chosen and JWT format documented
- must
Webhook secret stored in env, verified on every event
- must
Mirror tables for users + orgs in Postgres
- should
MFA / passkeys enabled by default
- should
Service account flow for CI / scripts
Where it lives
code references- middleware.ts // clerkMiddleware() at the edge
- app/api/webhooks/clerk/route.ts // user/org mirror sync
- .env.local // CLERK_SECRET_KEY, CLERK_WEBHOOK_SECRET
Notes
marginTreat the provider as authoritative for credentials and ephemeral session state, your DB as authoritative for application state. Anything else gets hairy.
Session & Tokens
Short-lived access JWTs validated at the edge, long-lived refresh tokens stored as httpOnly cookies. The web app gets cookies; the mobile app gets the JWT in secure storage and rotates via a refresh endpoint.
How it flows
step by step- 01step
Edge middleware reads the session cookie / Authorization header on every request.
- 02step
If the JWT is valid and unexpired, request proceeds with auth() populated.
- 03step
If expired, middleware issues a 401 and the client triggers a silent refresh.
- 04step
Refresh succeeds → new JWT issued, original request retried.
- 05step
Refresh fails (revoked / TTL-exceeded) → wipe local state, redirect to /sign-in.
Cases & edge cases
watch for theseToken refresh race: two parallel 401s should trigger only one refresh attempt (mutex / single-flight pattern).
Clock skew between client and server > 30s breaks JWT validation. Trust the server clock.
On mobile, store refresh tokens in OS keychain — never SharedPreferences / NSUserDefaults.
Long sessions (mobile) want absolute and idle timeouts both — kill the session 30 days after sign-in regardless of activity.
Decisions
pick once, live with itDecision 01
Cookie sessions or JWT-in-header?
pickCookies for the web, JWT in Authorization header for the mobile app.
whyCookies survive page refreshes and resist XSS-token-theft when httpOnly + SameSite=Lax. Mobile doesn't have a cookie jar that survives uninstall, so a refresh-token-in-keychain pattern is cleaner.
Checklist
ship readiness- must
Access token TTL ≤ 60 min
- must
Refresh token rotation on every use
- must
httpOnly + SameSite=Lax + Secure on session cookies
- should
Single-flight refresh in client SDKs
- should
Session revocation list / 'sign out everywhere'
Where it lives
code references- middleware.ts:8 // clerkMiddleware config
- lib/auth/refresh.ts // single-flight refresh helper
Notes
marginThe JWT is a cache, not a vault. Never put PII or roles in the token payload — fetch them server-side when needed. Token bloat hurts every request.
User & Profile
Your own users table mirrored from Clerk via webhook. Holds anything the app needs to JOIN on — display name, avatar URL, locale, marketing prefs — but NOT the email/password (Clerk owns those).
How it flows
step by step- 01step
Clerk fires user.created → /api/webhooks/clerk verifies the signature.
- 02step
Handler upserts (id, email, displayName, avatarUrl, createdAt) into public.users.
- 03step
On user.updated → reconciles the same row.
- 04step
On user.deleted → soft-delete (set deletedAt) — never hard-delete, billing rows reference it.
Cases & edge cases
watch for theseWebhook delivery is at-least-once → upsert by clerk user id, not insert.
Webhook can arrive before your in-app onboarding write → use ON CONFLICT and merge fields, don't overwrite app-set fields.
Email changes in Clerk don't change anything app-side except the mirror — billing email lives separately in Stripe.
Checklist
ship readiness- must
Mirror tables: users, organizations, memberships
- must
Soft-delete on user.deleted (GDPR-friendly)
- should
Replay endpoint for missed webhooks
Where it lives
code references- drizzle/schema/users.ts
- app/api/webhooks/clerk/route.ts:45 // user.* event handlers
Notes
marginTwo-source-of-truth is the worst feeling. Mirror means: Clerk is truth for auth fields, your DB is truth for app fields, and you write to neither carelessly.
Organization
The org is the tenant — billing happens per org, data is scoped by org_id, members are invited into an org. Users can belong to many orgs; each session has one active org.
How it flows
step by step- 01step
User creates an org during onboarding (or accepts an invite into an existing one).
- 02step
Clerk creates the organization + makes the user the 'admin' member.
- 03step
Webhook → app mirrors the org into public.organizations, creates a Stripe customer.
- 04step
auth().orgId is set on every subsequent request and becomes the tenancy key.
- 05step
Switching orgs is a Clerk API call → new JWT issued with the new orgId.
Cases & edge cases
watch for thesePersonal accounts vs orgs: model both as orgs (with a 'personal' flag). Avoids two code paths.
Org deletion is destructive — soft-delete and queue a hard-delete job after 30 days.
Don't ever JOIN across orgs in a single query — that's the bug class that loses you customers.
Checklist
ship readiness- must
org_id column on every tenant-scoped table
- must
requireOrg() server helper that throws if missing
- should
Org-switch UI in the app shell
Where it lives
code references- drizzle/schema/organizations.ts
- lib/auth/active-org.ts // server helper: requireOrg()
Notes
marginDecide early: B2C (personal accounts) or B2B (orgs first-class)? B2B is the default for SaaS — and 'personal' is just an org of one.
Roles & Permissions
Roles (admin / member / viewer) attached to a (user, org) pair. Permissions are derived from role + plan — e.g. 'admin can invite' AND 'plan supports invites'. Keep it boring: don't reach for ABAC or policy engines until you have a concrete reason.
How it flows
step by step- 01step
Membership row: (user_id, org_id, role, joinedAt).
- 02step
Server actions and route handlers call requirePermission('invite:create').
- 03step
Helper resolves: read role from membership → check role → check plan entitlements.
- 04step
Denies are 403 with a structured error code so the UI can render the right CTA ('Upgrade to Pro').
Cases & edge cases
watch for theseDon't put role checks in the UI alone — every server action revalidates.
Role escalation: only admins can change another member's role; only owner can remove the owner role.
Avoid 'super-admin' as a customer-facing role; reserve it for internal staff impersonation.
Vendors & tools
what implements thisClerk Roles
primaryBuilt-in roles on memberships; ship with admin/basic_member; you add the rest.
Pros
- Zero infra
- Maps 1:1 to your needs for most B2B SaaS
Cons
- You still need a permissions helper in code
Cerbos / Oso
alternateDedicated policy engines for complex / row-level authz.
Pros
- Scales to ABAC and policy-as-code
Cons
- Premature for most SaaS at <$10M ARR
Decisions
pick once, live with itDecision 01
Roles in JWT or always look up?
pickAlways look up server-side. Cache for the request lifetime.
whyPutting roles in the JWT means revocation is delayed by the token TTL. Lookup is cheap (one indexed query) and correctness > 1ms.
Where it lives
code references- lib/auth/permissions.ts
- lib/auth/require-permission.ts // throws AuthFailure.forbidden
Notes
marginRBAC + plan entitlements collapse into one helper: permissions(user, org) → Set<string>. UI and API both read from it.
SSO (SAML / OIDC)
Enterprise customers expect to sign in with their own IdP (Okta, Azure AD, Google Workspace). SAML 2.0 is still the lingua franca; OIDC is increasingly accepted. With Clerk this is one configuration per customer; with WorkOS it's one SSO connection per customer.
How it flows
step by step- 01step
Customer's IT admin uploads their IdP metadata via your admin portal (or you do it for them).
- 02step
Provider creates a tenant-scoped SSO connection (e.g. workos.sso.connections.create).
- 03step
Customer's users sign in via /sso → IdP-initiated or SP-initiated flow.
- 04step
Provider verifies SAML assertion, issues your standard JWT/session.
- 05step
JIT user provisioning creates / updates the mirror row on first sign-in.
Cases & edge cases
watch for theseDomain capture: enforce that users with @customer.com can ONLY sign in via SSO once enabled. Stops password back-doors.
Cert rotation: IdPs rotate signing certs — providers handle it but you should monitor 'SSO connection unhealthy' events.
Test in a sandbox IdP (e.g. Mock SAML, WorkOS testing IdP) before every customer onboarding.
Checklist
ship readiness- must
SSO setup self-serve in admin UI
- must
Domain-capture toggle per connection
- must
Admin audit log entry on every SSO config change
- should
Test IdP wired up in staging
Where it lives
code references- app/(admin)/sso/page.tsx // customer-facing SSO setup
- app/api/sso/callback/route.ts
Notes
marginSSO is a sales lever — most enterprise deals require it. Price it as part of the 'Enterprise' tier; don't give it away on Pro.
SCIM Provisioning
SCIM lets the customer's IdP create/update/deactivate users in your app automatically. When IT removes someone from Okta, they're disabled in your app within minutes — a hard requirement for SOC 2 enterprises.
How it flows
step by step- 01step
Customer enables SCIM in your admin portal → provider issues a SCIM endpoint URL + bearer token.
- 02step
Customer adds your app in Okta/Azure AD with that endpoint.
- 03step
IdP pushes user/group changes via SCIM 2.0 — provider relays them as webhook events.
- 04step
Your handler upserts / soft-deletes the mirror row and updates org membership.
Cases & edge cases
watch for theseSCIM 'soft' delete (active=false) ≠ hard delete — never destroy the row, just revoke session + remove from org.
Group → role mapping is the part everyone gets wrong. Document which IdP group maps to which role.
Rate-limit SCIM endpoints — IdPs do bulk syncs that look like attacks.
Where it lives
code references- app/api/scim/[...path]/route.ts // SCIM 2.0 endpoint (if self-hosting)
Notes
marginBuy SCIM, don't build it. WorkOS, Clerk and Stytch all handle the SCIM protocol details (filter syntax is a nightmare). You just consume their normalized webhooks.
Sign-in / Sign-up UI
The actual screens. With Clerk you drop <SignIn /> and <SignUp /> in your route and they handle email, OAuth, passkeys, MFA, account recovery, all of it. Don't build this yourself — there's no edge here.
How it flows
step by step- 01step
Public routes: /sign-in, /sign-up, /accept-invite (uses ticket from email).
- 02step
Successful auth → middleware redirects to /onboarding (new user) or /app (returning).
- 03step
/onboarding collects display name, creates first org, hits 'invite teammates' step.
Cases & edge cases
watch for theseSocial-only sign-in feels slick but blocks privacy-conscious users. Always offer email/passkey too.
After OAuth, ALWAYS land on a route that confirms identity ('Hi Sam!') before any destructive UI — fights account-confusion.
Magic link sign-in needs single-use, short-TTL tokens. Most providers handle this — verify it.
Checklist
ship readiness- must
Branded sign-in + sign-up routes
- must
Invite-acceptance flow handles new vs. existing users
- must
Onboarding creates first org + initial workspace
- should
Passkey enrollment shown post-first-sign-in
Where it lives
code references- app/sign-in/[[...sign-in]]/page.tsx
- app/sign-up/[[...sign-up]]/page.tsx
- app/onboarding/page.tsx
Notes
marginTheme the hosted UI with CSS variables, don't re-skin from scratch. You'll save 40+ hours and avoid breaking the accessible patterns the provider ships.
Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.