```html

WebAuthn in Production: Adding Face ID Biometric Auth to a Multi-Service Ecosystem Without a Single Password

I run 47 services across 23 ports. Every one of them sits behind Tailscale, every one of them speaks HTTPS, and until recently, every one of them that required authentication was protected by a password. That's a problem I knew I'd have to solve eventually — not because passwords are inherently weak in a closed Tailscale network, but because managing credential state across six operational domains (CoreOps, BizOps, COMET, DevOps, REOps, Rango) is operationally expensive and scales badly as the ecosystem grows. When I hit 26 autonomous agents running on battle rhythm and started thinking seriously about service-to-service trust boundaries, I decided to do it right: zero passwords, WebAuthn everywhere, Face ID as the primary authenticator.

Why WebAuthn and Not Something Simpler

The honest answer is that I could have used a shared secret or a simple JWT cookie and been fine for a single-user ecosystem. But ARKONA is also a research platform — it's how I study and build production-grade agent infrastructure. NIST SP 800-63B explicitly categorizes WebAuthn as an AAL3-capable authenticator when backed by a hardware bound key. That matters for the COMET governance framework, where I need verifiable, non-repudiable operator attestation before certain high-consequence agent actions are delegated. A password doesn't give me that audit trail. A FIDO2 assertion tied to a device-bound private key does.

WebAuthn also maps cleanly onto the threat model. My attack surface isn't credential stuffing from the public internet — Tailscale handles that. It's insider-context confusion: an agent taking an action under the wrong assumed operator identity, or a compromised service within the mesh impersonating a human approval. Biometric presence checks short-circuit that class of problem at the UX layer while the underlying cryptographic binding handles the trust layer.

The Architecture

The authentication layer sits as a standalone service on port 8025, which I call auth-gateway. It's a FastAPI application backed by a SQLite credential store (intentionally minimal — this is a single-operator system). Every other service that needs human-gated access routes its login flow through a redirect to auth-gateway, receives a signed session token on success, and validates that token against a shared HMAC secret. The session tokens carry a webauthn_verified: true claim that downstream services can assert on.

The registration flow runs once per device. I use py_webauthn on the server side and the browser's native navigator.credentials.create() on the client. The credential is stored with its associated AAGUID, public key, and sign count. On subsequent authentications, the sign count monotonically increments — a simple but effective replay detection mechanism that aligns with FIDO Alliance guidance.

# Simplified registration verification (py_webauthn)
from webauthn import verify_registration_response
from webauthn.helpers.structs import (
    AuthenticatorAttachment,
    ResidentKeyRequirement,
    UserVerificationRequirement,
)

registration_verification = verify_registration_response(
    credential=credential,
    expected_challenge=session["challenge"],
    expected_rp_id="arkonaresearch.tail85a379.ts.net",
    expected_origin="https://arkonaresearch.tail85a379.ts.net",
    require_user_verification=True,   # enforces biometric/PIN gate
)

# Store credential
db.execute(
    "INSERT INTO webauthn_credentials VALUES (?, ?, ?, ?, ?)",
    (
        registration_verification.credential_id,
        registration_verification.credential_public_key,
        registration_verification.sign_count,
        registration_verification.aaguid,
        user_id,
    ),
)

The require_user_verification=True flag is the critical line. It rejects any authenticator that doesn't perform a local biometric or PIN check before signing the assertion. On my MacBook, that means Face ID. On my iPhone, same. A YubiKey without PIN would fail. This is the enforcement point for NIST 800-63B AAL2 compliance — the authenticator must verify something you are or something you know locally before releasing the private key.

Wiring It Into the Ecosystem

The trickier engineering problem wasn't the WebAuthn protocol itself — that part is well-documented. It was making the session token mean something consistent across 47 services that were built at different times with different auth assumptions.

I solved it with a lightweight middleware I call ArkAuth. It's 40 lines of Python that every FastAPI service imports. It checks for a arkona_session cookie, validates the HMAC signature, checks token expiry (4-hour rolling window), and asserts webauthn_verified. If any check fails, it redirects to auth-gateway with a return_to parameter. The user authenticates via Face ID once, gets a session cookie scoped to .tail85a379.ts.net, and is silently passed through every service for the rest of their session.

# ArkAuth middleware — auth/middleware.py
from fastapi import Request, HTTPException
from fastapi.responses import RedirectResponse
import hmac, hashlib, time, json, base64

AUTH_GATEWAY = "https://arkonaresearch.tail85a379.ts.net:8025"

async def require_webauthn(request: Request, call_next):
    cookie = request.cookies.get("arkona_session")
    if not cookie:
        return RedirectResponse(f"{AUTH_GATEWAY}/login?return_to={request.url}")
    try:
        payload, sig = cookie.rsplit(".", 1)
        expected = hmac.new(SESSION_SECRET, payload.encode(), hashlib.sha256).hexdigest()
        assert hmac.compare_digest(sig, expected)
        claims = json.loads(base64.b64decode(payload))
        assert claims["exp"] > time.time()
        assert claims.get("webauthn_verified") is True
    except Exception:
        return RedirectResponse(f"{AUTH_GATEWAY}/login?return_to={request.url}")
    return await call_next(request)

For the agent services — the 26 autonomous agents that run on battle rhythm — I took a different approach. Agents don't authenticate via WebAuthn; they authenticate via short-lived API tokens minted by auth-gateway after a human WebAuthn session establishes intent. The human approves a batch of delegated actions through the COMET governance interface, Face ID confirms the approval, and auth-gateway mints scoped tokens that the agents use for the duration of that task delegation window. Human biometric presence is the root of the trust chain; agent tokens are leaves, not roots.

The SHA-256 Provenance Connection

One design decision I'm glad I made early: every WebAuthn assertion is logged with its credential ID, timestamp, and the action it gated, and that log entry is SHA-256 signed and appended to ARKONA's ecosystem-wide provenance chain. This means I can audit not just what happened in the ecosystem but which authenticated human session authorized each consequential action. MITRE ATT&CK's identity-based lateral movement techniques (T1550, T1078) are significantly harder to execute silently when every privileged action has a cryptographically attributed human fingerprint at the root.

The provenance chain is stored as an append-only log in the CoreOps domain and replicated nightly to Nextcloud via the Nextcloud Sync Agent. It's not blockchain theater — it's a simple hash chain that makes silent log tampering detectable. Humble infrastructure, serious guarantees.

What I Learned Running This for 90 Days

The biggest surprise was how much friction reduction matters for security adoption, even when you're the only user. Before WebAuthn, I'd occasionally skip authentication on internal tools because typing a password felt like overhead for a service I'd be on for 30 seconds. Face ID takes 300 milliseconds. I never skip it. The behavioral result is that I now have consistent, complete audit logs of my own ecosystem interactions — which turned out to be genuinely useful for debugging agent behavior and understanding how I actually use the system versus how I assumed I was using it.

The second lesson: centralize your challenge generation. I initially generated WebAuthn challenges at the individual service level before centralizing them in auth-gateway. Decentralized challenge state is a session fixation footgun waiting to happen. One service, one challenge store, one source of truth for credential state.

The third: test authenticator failure paths aggressively. Face ID can fail — bad lighting, sunglasses, a fresh scar. My fallback is a device PIN (still FIDO2, still hardware-bound, still WebAuthn), not a password. But I had to explicitly configure that fallback and test it, because the default browser behavior varies across platforms in ways that will surprise you.

The Takeaway

WebAuthn is not overkill for a personal ecosystem — it's the right default. The protocol is mature, the browser support is universal, and the operational complexity of running it is lower than maintaining password hygiene across dozens of services. More importantly, if you're building infrastructure where human-AI trust boundaries matter — where you need to know with cryptographic confidence that a human approved a consequential action, not an automated flow that inherited a stale session — biometric-backed WebAuthn is the only mechanism that gives you that guarantee at the UX layer. Passwords don't prove presence. Face ID does.

The auth-gateway is 400 lines of Python. It protects 47 services. It took one weekend to build and I've touched it twice since. That's the kind of security infrastructure that actually gets used.

```