Skip to content

Customer Authentication control mapping

Status: Current | Last reviewed: 2026-08-05

This document maps the Customer Authentication capability's implemented controls to three frameworks: NIST SP 800-63B (digital identity, authenticator assurance levels), the FFIEC guidance on Authentication and Access to Financial Institution Services and Systems (2021), and the HIPAA Security Rule (45 CFR §164.312). It is written for an auditor or a customer security review: every claim names the code that enforces it and the test that proves it. Technical background is in customer-authentication.md.

Scope: verification of a tenant's customer during a live conversation with an AI agent. Platform-user authentication (SSO/Authentik) is out of scope here.

Assurance model

Every method is tagged with the assurance it actually earns, and the achieved level is recomputed from evidence rows on every read — never trusted from mutable state. The composition rules live in assurance.py and are exercised by test_customer_auth_assurance.py.

Method Tagged assurance Rationale
Passkey (WebAuthn, user verification required) aal2_pr Multi-factor cryptographic authenticator, phishing-resistant (800-63B §5.1.9, §4.2.1)
OTP to SMS aal1, flagged restricted_channel Out-of-band via the PSTN, a restricted channel (800-63B §5.1.3.3)
OTP to email aal1 Possession of the mailbox; not an out-of-band device per 800-63B
Tool-based KBA not_a_factor Knowledge sourced from records; FFIEC treats KBA as insufficient alone
External OIDC aal1 floor; provider acr/amr may raise, never lower The IdP's claim is honoured only upward

Methods compose by factor category (possession_crypto, possession_channel, knowledge_sor, federated): two OTPs are one factor; OTP + passing KBA (where the tenant marks KBA counts_as_factor) composes to aal2.

NIST SP 800-63B

Requirement Control Where enforced Evidence
§5.1.4.2 / §5.1.9 — verifier stores no recoverable secret OTP codes stored only as HMAC-SHA256 over (challenge_id, code) with an env-only pepper (CUSTOMER_AUTH_OTP_PEPPER); plaintext never stored or logged otp.py code_hash test_customer_auth_otp.py::test_the_same_code_hashes_differently_per_challenge, ...::test_issuing_without_a_pepper_is_fatal (an unkeyed digest of a six-digit space is reversible, so a missing pepper refuses issuance rather than degrading)
§5.1.4.2 — codes generated with an approved RNG secrets-based CSPRNG, numeric, zero-padded, length clamped ≥6 otp.py generate_code test_customer_auth_otp.py generation tests
§5.1.4.2 — out-of-band secret validity ≤10 min Authored TTL clamped at the 600 s ceiling at runtime, not just at authoring config.py parse_node_policy test_customer_auth_otp.py clamp tests
§5.2.2 — throttling: ≤100 consecutive failures Per-challenge attempt cap (atomic SQL increment, no read-modify-write race) plus cross-conversation per-target Redis lockout (FAIL_LIMIT=5 / 15 min window / 15 min lock) store.py record_failed_attempt; lockout.py test_customer_auth_lockout.py::test_the_failure_limit_sits_under_the_800_63b_ceiling, test_customer_auth_passkey_service.py::test_burning_every_attempt_fails_the_challenge
§5.2.2 — throttling must not fail open Issuance fails closed on a lockout-store outage (503, never unthrottled); verification fails open deliberately because the SQL cap still bounds it lockout.py LockoutUnavailable test_customer_auth_lockout.py::test_checking_fails_closed_when_redis_is_down, ...::test_verifying_fails_open_when_redis_is_down
§5.1.3.3 — restricted channel (SMS) disclosed restricted_channel_only computed per grant and stamped into state + audit detail; ai-manager warns at publish when SMS is the sole AAL2 route assurance.restricted_channel_only; validate-node-capabilities.ts assurance + publish-validation tests
§7.1 / replay resistance — challenges are single-use mark_challenge_verified is a conditional pending → verified UPDATE; a replayed assertion/attestation finds nothing to claim; new OTP supersedes pending store.py test_customer_auth_passkey_service.py::test_a_claimed_challenge_cannot_be_verified_twice, ...::test_a_replayed_attestation_finds_nothing_to_claim
§5.1.9 — AAL2 passkey requires user verification required_assurance ∈ {aal2, aal2_pr} forces require_user_verification; a UV-less assertion is refused even if the library flag disagreed (defence in depth) service.py _require_user_verification test_customer_auth_passkey_service.py::test_uv_missing_at_aal2_is_refused_even_if_the_library_let_it_through
§5.1.9 — verifier compromise / clone detection Positive sign_count held to strict monotonicity via conditional UPDATE; regression refused and audited (sign_count_regression); zero counters (platform passkeys) exempt per spec service.py verify_passkey ...::test_a_positive_sign_count_that_does_not_advance_is_refused, ...::test_a_zero_sign_count_is_allowed_and_only_touches
§4.2.1 — verifier origin binding RP ID = surface-app hostname; override accepted only as a registrable suffix; expected origins are exactly the configured origin (loopback forms only when the origin is itself local) webauthn_ops.py test_customer_auth_webauthn.py RP/origin tests

FFIEC (Authentication and Access, 2021)

Expectation Control Where enforced
Layered authentication; MFA for higher-risk access required_assurance per agent; aal2 satisfiable only by passkey or by two composed factor categories assurance.compose + satisfies; publish validation errors on an aal2 config with no two-factor path
KBA is not sufficient alone KBA maps to not_a_factor; a lone passing KBA leaves the conversation unauthenticated with a factor_recorded audit event — the rule is control flow, not documentation service.py _grant; test_customer_auth_assurance.py
The authentication decision cannot be made by the customer-facing channel The LLM has no tool that asserts verification; KBA outcomes are read from the tool's own result frame by a server-side observer and reported over an authenticated channel; the observer accepts only a strict boolean attestation customer_auth_tool_handler.py; tests/test_customer_auth_kba.py::test_no_tool_lets_the_model_assert_verification, ...::test_anything_that_is_not_a_clear_pass_counts_as_a_failure
Call-back / caller-ID risk acknowledged ANI-defaulted OTP delivery is documented as possession-of-calling-number (spoofable) and earns aal1 only; reaching aal2 requires composition _resolve_target docstring; assurance table above
Monitoring and logging of authentication events Append-only customer_auth_events (see audit section); lockout enforcement, expiry, failures, grants each write an event store.record_event call sites throughout service.py
Enumeration resistance Unknown credential, cross-tenant credential, and wrong-subject all return an identical vague failure; the distinguishing reason exists only in the audit row service.py _fail_passkey_attempt; test_customer_auth_passkey_service.py::test_the_three_vague_failures_are_indistinguishable_to_the_caller

HIPAA Security Rule (45 CFR §164.312)

Standard Control
§164.312(a)(1) Access control — unique user identification Grants bind to a customer_identities row (customer_id + tenant-scoped external_ref); a passkey subject mismatch (link sent to A, B's key asserted) is refused
§164.312(b) Audit controls customer_auth_events records who/what/when/method/result/assurance/IP/UA for every transition. INSERT-only grants plus a DB trigger rejecting UPDATE/DELETE (027_customer_auth.sql). Audit detail carries masked targets only — never a code, an assertion, a token, or the caller's KBA answers (tests/test_customer_auth_kba.py::test_the_audit_detail_carries_no_caller_answers). A failed audit write logs the full event at ERROR as the compensating control rather than denying a decided authentication
§164.312(d) Person or entity authentication The capability itself; assurance level recorded per grant so the tenant can require aal2 for PHI-adjacent agents
§164.312(a)(2)(iv) / (e) Encryption OIDC client secrets AES-256-GCM under MASTER_ENCRYPTION_KEY in tenant_settings; OTP pepper env-only; WebAuthn public keys are not secrets; transport is TLS end to end
§164.312(c) Integrity State of record recomputed from challenge rows on every read; a tampered session_variables blob cannot claim a grant (service.load_state)

Cross-cutting properties

  • Session binding. The browser never holds a conversation id: the passkey link resolves through a share code, and surface-app's per-code server routes proxy to conversation-api, which re-checks tenant and conversation against the challenge row. A share code resolving to another tenant's challenge is refused and logged.
  • Tenant isolation. All four tables are RLS'd; customer_webauthn_credentials.credential_id is globally unique by necessity (a discoverable assertion carries only the credential id), so the service re-checks the resolved credential's tenant against the challenge tenant — the one place cross-tenant use can be caught (test_customer_auth_passkey_service.py::test_a_cross_tenant_credential_is_refused_before_the_signature_is_checked).
  • Lockout privacy. Redis lockout keys are peppered fingerprints — a keyspace dump does not enumerate who has been challenged (test_customer_auth_lockout.py::test_keys_do_not_contain_the_target).
  • Enrollment is not authentication. Passkey enrollment is offered only to an already-verified conversation (verified possession of the on-file contact during the call is the identity proofing) and never itself grants.
  • Retention. cleanup_expired_auth_challenges() clears stale challenge rows; audit events default to 2-year retention (operational policy; the table carries created_at for retention jobs).

Known limitations (disclosed by design)

Limitation Posture
SMS OTP over the PSTN (SIM swap, SS7) Tagged restricted channel per 800-63B; publish warning when it is the sole AAL2 route; passkey is the recommended AAL2 method
ANI spoofing ANI-defaulted delivery earns AAL1 only; never sufficient for AAL2 alone
Shared RP ID across tenants Passkeys are scoped to the platform surface-app domain; per-tenant RP domains are a planned hardening (Phase 5)
Customer phone/email stored plaintext under RLS Needed for deterministic ANI lookup in v1; column-level HKDF-per-tenant encryption is the documented Phase-5 hardening
Share codes are multi-resolve until claimed Mitigated by short TTL, single-use challenge claim, and WebAuthn itself as the real gate — the code opens the ceremony, it does not pass it