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 theauth-spikebranch).SPIKE-LOG.mdat 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:
- Secures every privileged endpoint (admin UIs, MCP tools that mutate, agent-callable APIs)
- Supports three user types — public, admin (privileged paths), end users (jobcache)
- Doesn't lock us into a vendor that monetizes the gateway between us and our users/money
- Ships in a reasonable amount of time given that auth is not the main business
- 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 publicjobcache-interfaceUI host — needs human auth for jobcache usersjobcache-interfaceMCP 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:
- Passwords: hashed with provider-specific algorithm/version — never portable
- MFA factors / passkey credentials: bound to relying party ID and stored in provider DB — re-enrollment required
- 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
subclaim, Microsoft'soidclaim) 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-authworker → 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:
- CF Rate Limiting on all auth endpoints (Better Auth doesn't ship this) — prevents enumeration/email-bombing
- Cookie attributes —
httpOnly: true, secure: true, sameSite: 'lax'in prod; verify env-aware config - 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)
- DB-backed sessions (default) — JWT-only sessions don't revoke until expiry
- 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.
Recommended execution order
- Extract
lib/auth-bearer/fromlib/qa-platform— non-blocking, applies regardless of human-auth choice (~1.5h) - Migrate ops/api, ops/mcp, jobcache-control, jobcache-ingest to use it; add bearer to
careervector-mcp(~2h) - Provision Better Auth (OAuth-only, Google) in
jobcache-interface— user table, session middleware, sign-in/sign-out (~3h) - New
careervector-authworker with Better Auth; gate qa/ops UIs via session cookie at.careervector.corbet.ch(~3h) - Stripe wiring for jobcache (deferred until needed)
Total ~9-10 hours of focused work. $0/mo forever. Migration cost forever bounded.
References
- Better Auth GitHub — MIT, 28.3k stars
- Auth.js docs — alternative to Better Auth
- Hanko import/export docs — verified the migration story today
- WorkOS pricing — verified per-connection costs
- Clerk pricing — verified Billing take-rate
- Logto pricing — verified Cloud feature pricing
- CLAUDE.md §1: CareerVector has no user accounts by design (link = identity)