arch-atlas
SaaS Atlas·Pillar 01 / 12

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.

8Nodes5Vendors3Decisions24Checklist2Flows

Primary stack

The default picks this pillar assumes

  • Clerk
  • Clerk Roles

Alternates worth knowing

Swap in when scale, cost or constraints change

  • Supabase Auth
  • WorkOS
  • Cerbos / Oso
01
IntegrationClerk (default) / Supabase Auth

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
  1. 01

    User opens /sign-in → Clerk's hosted component handles email/OAuth/passkeys.

    step
  2. 02

    On success Clerk issues a session JWT (short-lived) + a refresh token (httpOnly cookie).

    step
  3. 03

    Next.js middleware (clerkMiddleware) validates the JWT on every request, hydrates auth() on the server.

    step
  4. 04

    Webhooks from Clerk (user.created, organization.created, organizationMembership.created) keep your Postgres mirror in sync.

    step
  5. 05

    Your code never sees a password — only auth().userId and auth().orgId.

    step

Cases & edge cases

watch for these

Switching 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 this

Clerk

primarysite

Hosted 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)

Supabase Auth

alternatesite

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

WorkOS

alternatesite

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 it
  • Decision 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
  • Provider chosen and JWT format documented

    must
  • Webhook secret stored in env, verified on every event

    must
  • Mirror tables for users + orgs in Postgres

    must
  • MFA / passkeys enabled by default

    should
  • Service account flow for CI / scripts

    should

Where it lives

code references
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

margin

Treat the provider as authoritative for credentials and ephemeral session state, your DB as authoritative for application state. Anything else gets hairy.

02
PlatformJWT · Refresh · Middleware

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
  1. 01

    Edge middleware reads the session cookie / Authorization header on every request.

    step
  2. 02

    If the JWT is valid and unexpired, request proceeds with auth() populated.

    step
  3. 03

    If expired, middleware issues a 401 and the client triggers a silent refresh.

    step
  4. 04

    Refresh succeeds → new JWT issued, original request retried.

    step
  5. 05

    Refresh fails (revoked / TTL-exceeded) → wipe local state, redirect to /sign-in.

    step

Cases & edge cases

watch for these

Token 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 it
  • Decision 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
  • Access token TTL ≤ 60 min

    must
  • Refresh token rotation on every use

    must
  • httpOnly + SameSite=Lax + Secure on session cookies

    must
  • Single-flight refresh in client SDKs

    should
  • Session revocation list / 'sign out everywhere'

    should

Where it lives

code references
References
  • middleware.ts:8 // clerkMiddleware config
  • lib/auth/refresh.ts // single-flight refresh helper

Notes

margin

The 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.

03
DomainMirror table · Public profile

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
  1. 01

    Clerk fires user.created → /api/webhooks/clerk verifies the signature.

    step
  2. 02

    Handler upserts (id, email, displayName, avatarUrl, createdAt) into public.users.

    step
  3. 03

    On user.updated → reconciles the same row.

    step
  4. 04

    On user.deleted → soft-delete (set deletedAt) — never hard-delete, billing rows reference it.

    step

Cases & edge cases

watch for these

Webhook 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
  • Mirror tables: users, organizations, memberships

    must
  • Soft-delete on user.deleted (GDPR-friendly)

    must
  • Replay endpoint for missed webhooks

    should

Where it lives

code references
References
  • drizzle/schema/users.ts
  • app/api/webhooks/clerk/route.ts:45 // user.* event handlers

Notes

margin

Two-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.

04
DomainTenant container

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
  1. 01

    User creates an org during onboarding (or accepts an invite into an existing one).

    step
  2. 02

    Clerk creates the organization + makes the user the 'admin' member.

    step
  3. 03

    Webhook → app mirrors the org into public.organizations, creates a Stripe customer.

    step
  4. 04

    auth().orgId is set on every subsequent request and becomes the tenancy key.

    step
  5. 05

    Switching orgs is a Clerk API call → new JWT issued with the new orgId.

    step

Cases & edge cases

watch for these

Personal 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
  • org_id column on every tenant-scoped table

    must
  • requireOrg() server helper that throws if missing

    must
  • Org-switch UI in the app shell

    should

Where it lives

code references
References
  • drizzle/schema/organizations.ts
  • lib/auth/active-org.ts // server helper: requireOrg()

Notes

margin

Decide early: B2C (personal accounts) or B2B (orgs first-class)? B2B is the default for SaaS — and 'personal' is just an org of one.

05
DomainRBAC model

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
  1. 01

    Membership row: (user_id, org_id, role, joinedAt).

    step
  2. 02

    Server actions and route handlers call requirePermission('invite:create').

    step
  3. 03

    Helper resolves: read role from membership → check role → check plan entitlements.

    step
  4. 04

    Denies are 403 with a structured error code so the UI can render the right CTA ('Upgrade to Pro').

    step

Cases & edge cases

watch for these

Don'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 this

Clerk Roles

primary

Built-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

alternate

Dedicated 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 it
  • Decision 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
References
  • lib/auth/permissions.ts
  • lib/auth/require-permission.ts // throws AuthFailure.forbidden

Notes

margin

RBAC + plan entitlements collapse into one helper: permissions(user, org) → Set<string>. UI and API both read from it.

06
IntegrationEnterprise sign-in

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
  1. 01

    Customer's IT admin uploads their IdP metadata via your admin portal (or you do it for them).

    step
  2. 02

    Provider creates a tenant-scoped SSO connection (e.g. workos.sso.connections.create).

    step
  3. 03

    Customer's users sign in via /sso → IdP-initiated or SP-initiated flow.

    step
  4. 04

    Provider verifies SAML assertion, issues your standard JWT/session.

    step
  5. 05

    JIT user provisioning creates / updates the mirror row on first sign-in.

    step

Cases & edge cases

watch for these

Domain 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
  • SSO setup self-serve in admin UI

    must
  • Domain-capture toggle per connection

    must
  • Admin audit log entry on every SSO config change

    must
  • Test IdP wired up in staging

    should

Where it lives

code references
References
  • app/(admin)/sso/page.tsx // customer-facing SSO setup
  • app/api/sso/callback/route.ts

Notes

margin

SSO is a sales lever — most enterprise deals require it. Price it as part of the 'Enterprise' tier; don't give it away on Pro.

07
IntegrationDirectory sync

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
  1. 01

    Customer enables SCIM in your admin portal → provider issues a SCIM endpoint URL + bearer token.

    step
  2. 02

    Customer adds your app in Okta/Azure AD with that endpoint.

    step
  3. 03

    IdP pushes user/group changes via SCIM 2.0 — provider relays them as webhook events.

    step
  4. 04

    Your handler upserts / soft-deletes the mirror row and updates org membership.

    step

Cases & edge cases

watch for these

SCIM '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
References
  • app/api/scim/[...path]/route.ts // SCIM 2.0 endpoint (if self-hosting)

Notes

margin

Buy 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.

08
PresentationHosted or embedded

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
  1. 01

    Public routes: /sign-in, /sign-up, /accept-invite (uses ticket from email).

    step
  2. 02

    Successful auth → middleware redirects to /onboarding (new user) or /app (returning).

    step
  3. 03

    /onboarding collects display name, creates first org, hits 'invite teammates' step.

    step

Cases & edge cases

watch for these

Social-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
  • Branded sign-in + sign-up routes

    must
  • Invite-acceptance flow handles new vs. existing users

    must
  • Onboarding creates first org + initial workspace

    must
  • Passkey enrollment shown post-first-sign-in

    should

Where it lives

code references
References
  • app/sign-in/[[...sign-in]]/page.tsx
  • app/sign-up/[[...sign-up]]/page.tsx
  • app/onboarding/page.tsx

Notes

margin

Theme 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.

Annotate0 strokes

Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.