Skip to content
fitme·story
Case studies
v7.8.1 · 5 min read · 2026-05-07

UCC Passkey Auth — Replacing basic-auth on the operator dashboard with WebAuthn

Two-PR cross-repo ship that replaces shared HTTP basic-auth on /control-room/* with WebAuthn passkeys. Per-operator identity, per-device credentials, server-side session allowlist (instant revoke), append-only audit log synced daily into FT2 framework-health. Migration is reversible at every step via UCC_AUTH_MODE env switch (basic / passkey / both). 5 screens · 5 API routes · 22 new files in fitme-story · 19/19 unit tests pass · tsc clean · next build clean. Second feature shipped via the v7.8.1 protocol (Mechanism C session attribution + isolated worktree from Phase 1 + Tier 2.2 logging + Mechanism A coverage telemetry). Structurally closes the asymmetry where iOS users had passkeys but operators had a shared phishable password.

0
shared phishable passwords on /control-room/* — replaced by per-operator WebAuthn passkeys
iOS users shipped passkeys on 2026-05-01; the operator dashboard was still gated by one shared HTTP basic-auth credential. This feature closes that asymmetry — per-operator identity, per-device credentials, server-side session allowlist (instant revoke). Target: 0 unauthenticated dashboard reads/day post-cutover.
What do T1 / T2 / T3 mean?
T1Instrumented — from a ledger/commit
T2Declared — stated, not measured
T3Narrative — estimate from memory

Why this exists

The Unified Control Center shipped on 2026-05-06 [T1] gating /control-room/* behind shared HTTP basic-auth. T2.5 — "passkey replacement for basic-auth" — was explicitly deferred because the Astro→Next.js migration was already a 22-PR effort across two repos.

That deferral surfaced a structural asymmetry: the iOS app shipped passkeys for end users on 2026-05-01 via auth-polish-v2 [T1]. The operator surface — which has access to every framework readout, ship event, ledger, and case study — was gated by a phishable shared credential. Operator security weaker than user security is exactly the wrong ratio.

This feature closes that asymmetry one day after the v7.8.1 framework feature that introduced the protocol it followed.

What landed

fitme-story side — the actual gate (PR #55)

Five screens, five API routes (Node.js runtime), four reusable components, one Vercel Cron route, and one extended proxy.ts switch. The gate matrix:

UCC_AUTH_MODEBehavior
basic (default; pre-cutover)Existing basic-auth path; no behavior change
both (cutover window)Accept either basic-auth OR passkey session cookie; log which path was used
passkey (steady state)Require iron-session cookie; redirect-to-sign-in on miss (NOT 401)

The cookie is iron-session AES-GCM-sealed (HttpOnly + Secure + SameSite=Strict, scoped to /control-room, 24h hard cap with 12h sliding refresh). Server-side ucc:session:<sid> allowlist in Upstash Redis enables instant revoke — flipping revokedAt on a credential bulk-clears every session for that operator on the next request.

FT2 side — cross-repo audit-log sync (PR #248)

Daily GitHub Actions cron (05:17 UTC) pulls a Vercel Blob populated by the fitme-story Vercel Cron (05:13 UTC) and commits it to .claude/logs/ucc-auth-events.jsonl. The cross-repo workflow is repo-variable gated — absence of UCC_AUDIT_BLOB_URL is a no-op, not a failure. This matches the migration plan's reversibility property at the infrastructure layer.

Defenses

  • Phishing → WebAuthn binds assertion to RP-ID (fitme-story.vercel.app); browser refuses to sign for any other origin.
  • Replay → Per-ceremony challenge in Redis (60s TTL, single-use) + CAS counter check. Hardware authenticators that always report counter=0 explicitly handled.
  • Session theft → HttpOnly + Secure + SameSite=Strict + server-side allowlist. Any compromised cookie is one revoke away from invalidation.
  • PII in audit log → SHA-256 hashes for credential IDs + session IDs; IPv4 truncated to /24, IPv6 to /48; UA stripped to family. Tests assert no raw value appears in the JSONL output.

How this got shipped — the v7.8.1 protocol

Second feature shipped via the v7.8 protocol (after framework-v7-8.1 itself, the same-day morning ship). The protocol mandates:

  1. Isolated worktree from Phase 1 onward (Mode C of BRANCH_ISOLATION_VIOLATION)
  2. Mechanism C session attribution via .claude/active-feature lockfile
  3. Tier 2.2 contemporaneous logging on every phase transition
  4. v4.X preflight gates (/ux preflight + /design preflight) before Phase 4
  5. v4.X pre-merge-review gates (/ux pre-merge-review + /design pre-merge-review) before Phase 7
  6. FEATURE_CLOSURE_COMPLETENESS gate at Phase 8 (7 frontmatter fields + Q7 kill_criteria_resolution + Q6 PR parity)

Compliance ledger on this ship: ✓ ✓ ✓ ✓ ✓ ✓ — the kill_criteria_resolution field is set to Pending — week-1 telemetry gate per the documented forward-only path.

Honest disclosures

  1. One bug caught + fixed during Phase 5. iron-session.unsealData returns {} on garbage input rather than throwing. My unsealSession wrapper would have returned an empty SessionPayload-shaped object (truthy, defensive checks pass). Caught by the "returns null on garbage input" test. Fix: explicit field-presence check on all 4 payload fields. The test asserts the bug stays dead.
  2. 4 modules refactored to lazy env reads. Static reads at module-load time caused test isolation issues (env-var override after import had no effect because imports hoist). Refactored to read at call-time. This is also the right production behavior.
  3. Figma node IDs are absent. Per state.json.figma_build_status: "deferred_to_prompt". The fitme-story dashboard has no Figma-file mapping by design — built code-first during the UCC migration. Portable build prompt at docs/prompts/ui/2026-05-07-ucc-passkey-auth-design-build.md is the v4.X documented escape hatch.
  4. kill_criteria_resolution is "pending". Time-deferred resolution (T+7d post-cutover). Frontmatter records the criteria + a placeholder; actual update lands in §99 after the cutover window.
  5. Patterns ported, not extracted. auth-polish-v2's capability-detection + consent-sheet + inline-error-banner + mock-fixture-test patterns are mirrored web-side rather than extracted into a shared lib. Consolidation can happen if a third auth surface emerges.

Cross-references

2026-05-16 cutover update

Setup-guide Parts 1-6 EXECUTED 2026-05-16, nine days post-ship. The dashboard now runs at UCC_AUTH_MODE=both: passkey AND legacy basic-auth both accepted on /control-room/* during the cutover window. One platform passkey registered for the first operator (Redis-verified). The legacy DASHBOARD_USER/DASHBOARD_PASS credentials are preserved as a single-env-var rollback path.

Remaining cutover steps (calendar-anchored):

  • 2026-05-17 — wire UCC_AUDIT_BLOB_URL repo variable in FT2 (after tonight's 05:13 UTC cron fires); activates the daily audit-log GHA
  • 2026-05-18+ — verify framework-health passkey panel renders with real audit data (depends on the above + 1 daily sync)
  • 2026-05-23 — T+7d kill-criteria checkpoint (K1 registration ceremony failure rate ≤ 5%, K2 zero counter_replay events, K3 function p50 ≤ 500ms sustained 24h)
  • 2026-05-28+ — Part 8 UCC_AUTH_MODE=passkey + drop legacy basic-auth (calendar-gated post-v7.9 promotion to avoid co-firing two large infra changes)

Four operational quirks captured for future operators: the Vercel CLI env add silently writes empty values in headless mode (worked around via REST API direct POST); the bootstrap CLI does not fall back from UPSTASH_REDIS_REST_* to KV_REST_API_* (worked around via inline export); sensitive-typed env vars are write-only by design (verify writes via non-sensitive dev copy); local .vercel/project.json may point to legacy fit-tracker2 project (re-link to fitme-story before provisioning). Full details + reproduction commands in the source case study §99.

Honest disclosures
  • One bug caught + fixed during Phase 5 testing. iron-session's unsealData returns {} on garbage input rather than throwing — my unsealSession wrapper would have returned an empty SessionPayload-shaped object. Caught by the "returns null on garbage input" test. Fix: explicit field-presence check on all 4 payload fields. Test asserts the bug stays dead.
  • 4 modules refactored to lazy env reads during Phase 5. Static reads of process.env at module-load time caused test isolation issues (env-var override after import had no effect because imports hoist). Refactored audit-log.ts, load-events.ts, the cron route, and the bootstrap script to read env at call-time.
  • Figma node IDs are absent. Per state.json.figma_build_status: "deferred_to_prompt". The fitme-story dashboard has no current Figma-file mapping — it was built code-first during the UCC migration. The portable build prompt is the v4.X documented escape hatch.
  • kill_criteria_resolution is "Pending — week-1 telemetry gate." The kill criteria are time-deferred (resolution at T+7d post-cutover). The frontmatter records the criteria + the resolution placeholder; the actual T+7d update will land in §99 after the cutover window.
Kill criterion · not fired
  • If passkey registration ceremony fails on >5% of attempted devices in week 1, fall back to UCC_AUTH_MODE=both and reopen scope.
  • Any counter_replay event in audit log = hard stop (replay attack detected).
  • Function p50 >500ms sustained 24h → fall back.