Study: Auth platform selection

⚠️ AUTH IS BURIED — 2026-05-18. The implementation work captured below was paused mid-spike. CV and JobCache currently run with no authentication enforcement on any endpoint that previously required a bearer token. This study stays here because the design will resume when we have the mindshare for it; right now it is historical research, not the current production state.

Thaw path: git checkout auth-stashed-v1 (formerly the auth-spike branch). SPIKE-LOG.md at the branch root has 6+ hours of journaled learnings with every gotcha as inline code comments.

Status: research complete, decision deferred Date: 2026-05-18

Goal

Pick an authentication architecture for CareerVector + JobCache that:

  1. Secures every privileged endpoint (admin UIs, MCP tools that mutate, agent-callable APIs)
  2. Supports three user types — public, admin (privileged paths), end users (jobcache)
  3. Doesn't lock us into a vendor that monetizes the gateway between us and our users/money
  4. Ships in a reasonable amount of time given that auth is not the main business
  5. Survives our uncertainty about jobcache's eventual shape and customer mix

User types

Type Where What they need
Public CV main UI (careervector-ui, link=identity), status pages, wiki No auth at all
Admin CV qa/ops/status UIs, the corresponding *-api and *-mcp workers, jobcache qa/ops/status UIs Human session for the UIs; bearer tokens for service/agent calls
End user jobcache UI (private, B2B) Human session, eventually with billing metadata
Service / agent CI runners, MCP-calling agents like Claude Code Scoped bearer tokens (the qa-platform pattern)

CV's main app has no user accounts by design (CLAUDE.md §1) — workspace slug = identity. This study covers only the admin and end-user tiers + the service/agent tier.

Current state (May 2026)

Four different bearer-auth implementations exist today:

Where Mechanism Health
lib/qa-platform/src/{api-core,mcp-core}/middleware/auth.ts SHA-256-hashed bearer tokens with scopes (admin, ci-ingest, mcp-read, mcp-trigger) Solid — extract upward
ops/api/src/lib/auth.ts:84 Cloudflare-Access JWT presence-check (signature NOT validated) + bearer + cookie Security gap — JWT must be properly validated
lib/server/src/jobcache-control-auth.ts Plain string bearer comparison Inconsistent with qa-platform
jobcache/ingest/src/jobs/heavy-worker-app.ts:65-70 Plain string bearer comparison Inconsistent with qa-platform

Workers with no auth gating that probably need it:

  • careervector-mcp — agent-facing, currently public
  • jobcache-interface UI host — needs human auth for jobcache users
  • jobcache-interface MCP host — needs bearer auth at minimum

Options considered

Identity-provider SaaS

Provider License model Realistic monthly cost for our B2B shape Verdict
WorkOS AuthKit Proprietary $349/mo with 1 enterprise customer (custom domain $99 + 1 SAML $125 + 1 SCIM $125) Rejected — monetizes the lock-in surface
Clerk + Clerk Billing Proprietary $25/mo base + 0.7% of MRR forever Rejected — perpetual revenue tax
Kinde Proprietary $25/mo + per-feature Rejected — same shape as Clerk, smaller mindshare
Auth0 (Okta) Proprietary Free 25k MAU, then expensive Rejected — known migration horror at scale

The recurring theme: SaaS providers monetize on the exact surface that hurts to migrate (users + payments). Migration tax = "every active user resets password / re-enrolls MFA / re-links OAuth."

Self-hosted IdP services

Tool License Resource footprint Catches
Keycloak Apache 2.0 4-8 GB RAM (Java) Mature, heavy, ops burden — overkill for our scale
Logto OSS MPL-2.0 8 GiB RAM, 256 GiB disk recommended; OSS = single admin only The single-admin limit is a real ding for our 2-3 CV admin count. Hidden gotcha not visible from marketing.
Hanko self-hosted AGPL-3.0 (backend) + MIT (frontend) Light Go service + Postgres (~256-512 MB RAM) AGPL viral only if we modify the source. Drop-in <hanko-auth> web components are MIT.
Zitadel Apache 2.0 2-4 GB RAM (Go) Newer, less mindshare than Keycloak

Auth libraries (no extra service)

Library License Stars Shape
Better Auth MIT 28.3k TS-native, plugin-first, framework-agnostic
Auth.js (formerly NextAuth.js) ISC ~24k TS-native, config-driven, more battle-tested
Lucia Discontinued March 2025 — maintainer recommends Better Auth

Critical finding: the "Cloud ↔ self-hosted seamless switch" marketing claim

Multiple OSS-with-Cloud providers (Hanko, Logto) advertise "no vendor lock-in, move between Cloud and self-hosted anytime." Verified against their own docs:

Hanko Cloud → self-hosted (May 2026 reality):

  • Export requires a paid plan (Admin API only)
  • Export JSON schema documented contains: user_id, email, created_at, updated_at
  • Export schema does NOT include: password hashes, MFA factors, passkey credentials, OAuth provider links
  • Their own docs: "A dedicated export feature matching the import functionality will be added to Hanko Cloud in the near future"
  • Their own docs: "Currently with our help, but full self-service import/export will soon be possible"

Logto Cloud → self-hosted:

  • FAQ: "Safely migrate your critical data and resources to an alternative platform. Reach out to us for expert assistance during this process"
  • i.e., sales conversation, not a button

So the "no vendor lock-in" claim is architecturally true (same Docker image, same schema) but operationally aspirational today. Today both Hanko Cloud and Logto Cloud have the same migration tax as a fully proprietary SaaS — forced password resets, MFA re-enrollment, OAuth re-link for every active user.

The only path with genuinely zero migration cost is starting self-hosted from day 1, OR owning the user table outright via a library.

Migration-pain principle

Migration pain has three sources:

  1. Passwords: hashed with provider-specific algorithm/version — never portable
  2. MFA factors / passkey credentials: bound to relying party ID and stored in provider DB — re-enrollment required
  3. OAuth tokens: per-relying-party — re-link required

To eliminate migration pain:

  • Own the user table (so the user records persist regardless of auth provider)
  • Use stable provider subjects (Google's sub claim, Microsoft's oid claim) as foreign keys to provider identity
  • Don't store passwords at all — eliminate (1)
  • Don't enroll MFA in our system — eliminate (2) by federating to Google/Microsoft which do MFA at their layer
  • Use OAuth-only flows — (3) becomes "user clicks Sign in with Google again," not real migration

This gives us the property: the auth library can be swapped at any time without any user-visible migration.

Decision (tentative, 2026-05-18)

A two-mode design:

Mode Audience Tool Why
Service / agent auth CI, MCP-calling agents, jobcache-ingest cron lib/auth-bearer/ (extracted from lib/qa-platform) Already battle-tested, SHA-256-hashed registry, scope-gated, stateless
Human auth CV admins, jobcache users Better Auth, OAuth-only mode (Google + Microsoft) — sessions in our own DB Pure MIT library, no extra service, owns nothing, swappable
Payments later jobcache subscribers (future) Stripe direct — webhook to our DB Portable, no provider markup

Public surfaces (CV main UI, status pages, wiki) keep their current "no auth needed" model.

Schema sketch

users
├── id          UUID (we mint)
├── google_sub  TEXT NULLABLE  (Google OAuth sub claim, unique if present)
├── microsoft_sub TEXT NULLABLE (Microsoft OAuth oid claim, unique if present)
├── email       TEXT
├── role        TEXT  ('admin' | 'user')
├── plan        TEXT NULLABLE  (jobcache subscriber plan, later)
├── created_at  TIMESTAMP
└── …
sessions       (DB-backed, signed token in cookie)

Two DBs:

  • careervector-auth worker → D1 (CV admin users)
  • jobcache-interface → Cockroach (jobcache users)

Sessions are cookies scoped to .careervector.corbet.ch / .jobcache.corbet.ch so any worker under those zones can read them.

Migration optionality preserved

Future move Cost
Better Auth → Auth.js ~1 day swap; both libraries handle the same OAuth flow against our schema
Better Auth → custom ~300 LOC OAuth Easy; we own the DB
Better Auth → Hanko self-hosted (drop-in UI) Medium; map users.id to Hanko's user model
Better Auth → WorkOS Connections-as-a-Service for SAML (when one enterprise customer asks) Easy; bolt on as another OIDC provider in Better Auth
Self-host → managed (Logto/Hanko Cloud) Possible but inherits SaaS lock-in characteristics; no reason to do this

Footgun checklist for Better Auth

Defaults handle most of these correctly. The ones we must verify:

  1. CF Rate Limiting on all auth endpoints (Better Auth doesn't ship this) — prevents enumeration/email-bombing
  2. Cookie attributeshttpOnly: true, secure: true, sameSite: 'lax' in prod; verify env-aware config
  3. Uniform login errors — opt in to "invalid credentials" instead of "user not found" / "wrong password" (low priority if we go OAuth-only since we never see passwords)
  4. DB-backed sessions (default) — JWT-only sessions don't revoke until expiry
  5. No password storage — confirmed by OAuth-only mode, no MFA enrolled in our system

What we explicitly DON'T build now

To preserve migration-zero property:

  • ❌ Email/password authentication (creates a hash-format migration debt)
  • ❌ Magic link via email (requires transactional email infra)
  • ❌ MFA enrollment in our system (Google/Microsoft handle MFA at IdP layer)
  • ❌ Passkeys at app level (passkey credentials are bound to relying party ID, complicated migration story)
  • ❌ Cloud auth provider at any tier

If any of these are demanded later (an enterprise customer pushing for in-app MFA, say), we'll add them with migration debt as a conscious trade.

Open questions

  • Microsoft OAuth for v1: do enough small-business customers use Microsoft 365 to warrant supporting it on day 1, or start Google-only and add Microsoft when asked?
  • jobcache signup model: invite-only (we mint accounts) or open OAuth signup? Invite-only is safer until product-market fit is clear.
  • SAML escape valve: if/when an enterprise customer demands SAML SSO, bolt on WorkOS Connections-as-a-Service ($125/mo per connection) plugged into Better Auth as an OIDC provider. Pay only when revenue justifies. Decision deferred until first ask.
  • Payments timing: deferred until first jobcache subscriber lines up. Stripe direct, not Clerk Billing or WorkOS-wrapped.
  1. Extract lib/auth-bearer/ from lib/qa-platform — non-blocking, applies regardless of human-auth choice (~1.5h)
  2. Migrate ops/api, ops/mcp, jobcache-control, jobcache-ingest to use it; add bearer to careervector-mcp (~2h)
  3. Provision Better Auth (OAuth-only, Google) in jobcache-interface — user table, session middleware, sign-in/sign-out (~3h)
  4. New careervector-auth worker with Better Auth; gate qa/ops UIs via session cookie at .careervector.corbet.ch (~3h)
  5. Stripe wiring for jobcache (deferred until needed)

Total ~9-10 hours of focused work. $0/mo forever. Migration cost forever bounded.

References

Source: wiki/content/studies/auth-pick/README.md