Status: Accepted
Date: 2026-04-21
Deciders: product owner (user), software-architect
Related: ADR 0001, docs/architecture/auth.md
Invariant: the backend must never store a credential it could replay. ADR 0001 picks passkeys as the auth factor, which makes compliance possible (public keys are not credentials). This ADR is about how we enforce that the invariant is never quietly violated by a future commit — a well-meaning "just add a backup password column" PR, or a migration that re-introduces a secret field under a harmless name.
We need the invariant to be defended at three levels: schema, code, and CI.
Enforcement is layered — defense in depth. A single layer is insufficient; all three ship together.
users table has no password, password_hash, salt, secret, recovery_code, totp_seed, pin, or any column that could hold one. These names are documented in the CI grep list.webauthn_credentials table holds only: credential id, public key (COSE), sign count, transports, aaguid, device label, timestamps, backup flags. A CHECK (length(public_key) > 0) guard prevents an empty/degenerate row.backend_v2/db/migrations/ and are reviewed by the CI grep below before merge.docs/DOCUMENTATION_GOVERNANCE.md and referenced by .github/PULL_REQUEST_TEMPLATE.md) forces reviewers to tick "no secrets added to DB or logs" for any diff touching backend_v2/db/ or backend_v2/api/routes/auth*.backend_v2/api/services/webauthn.py, to be created in implementation sub-card). No route hand-rolls challenge handling.scripts/ci/check_no_credential_fields.sh greps the diff for forbidden identifiers in any of backend_v2/db/**, backend_v2/api/**, backend_v2/models/**:
password|password_hash|pwd_hash|pw_hash|hashed_password|
recovery_code|backup_code|totp_seed|totp_secret|
security_answer|pin_hash|sms_code|otp_code
A match fails the ci/security job. An intentional false positive (e.g. the string appears in a test comment explaining why it is banned) must be allowlisted explicitly in the script with a comment and linked ADR.gitleaks config (already scoped per recent commit d32cd0b) is extended to catch accidental commits of long-random-string-looking values in migrations.tests/integration/test_no_credential_storage.py asserts that after a full registration + login cycle, no row in any table contains a base64 string longer than 64 bytes outside of webauthn_credentials.public_key and audit_log.context.user_password_negative_case). Allowlisting is friction by design — we prefer "explicit exception with reasoning" over silent pass.Rejected. A migration is a fleeting moment in review; a CI grep is running continuously.
Rejected as primary mechanism. SQLite doesn't really support this, and we don't want the enforcement to vanish when we switch DB engines.
Rejected as sole mechanism; accepted as an additional layer. Pre-commit hooks can be bypassed locally, CI cannot.
Rejected as primary. Catching at runtime means the bad code shipped. CI-time is preferable.