ADR 0002 — No Stored Credentials (Enforcement)
Status: Accepted
Date: 2026-04-21
Deciders: product owner (user), software-architect
Related: ADR 0001, docs/architecture/auth.md
Context
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.
Decision
Enforcement is layered — defense in depth. A single layer is insufficient; all three ship together.
1. Schema-level
- The
userstable has nopassword,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. - The
webauthn_credentialstable holds only: credential id, public key (COSE), sign count, transports, aaguid, device label, timestamps, backup flags. ACHECK (length(public_key) > 0)guard prevents an empty/degenerate row. - Migrations live in
backend_v2/db/migrations/and are reviewed by the CI grep below before merge. - SQLite is run with WAL + default-encrypted filesystem; public keys are not a secret but we encrypt the disk anyway because the audit log and email addresses are PII.
2. Code-level
- A code-review checklist (in
docs/archive/root/DOCUMENTATION_GOVERNANCE.mdand referenced by.github/PULL_REQUEST_TEMPLATE.md) forces reviewers to tick "no secrets added to DB or logs" for any diff touchingbackend_v2/db/orbackend_v2/api/routes/auth*. - All WebAuthn verification goes through a single module (
backend_v2/api/services/webauthn.py, to be created in implementation sub-card). No route hand-rolls challenge handling. - Email tokens and session ids are stored hashed (SHA-256 of the high-entropy random value). The raw token is only ever in transit. This is not a "stored credential" in the invariant sense because there is no user-memorized secret behind it, but we hash them out of defense in depth and so they cannot be lifted from a DB dump.
3. CI-level
- New script
scripts/ci/check_no_credential_fields.shgreps the diff for forbidden identifiers in any ofbackend_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_codeA match fails theci/securityjob. 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. - The existing
gitleaksconfig (already scoped per recent commitd32cd0b) is extended to catch accidental commits of long-random-string-looking values in migrations. - Integration smoke test
tests/integration/test_no_credential_storage.pyasserts that after a full registration + login cycle, no row in any table contains a base64 string longer than 64 bytes outside ofwebauthn_credentials.public_keyandaudit_log.context.
Consequences
Positive
- Violations require actively defeating three mechanisms. A quiet regression is unlikely.
- The grep is self-documenting: a new engineer reading the CI script learns the invariant.
- Schema review becomes mechanical rather than vibes-based.
Negative
- The grep will occasionally false-positive (e.g. a test fixture named
user_password_negative_case). Allowlisting is friction by design — we prefer "explicit exception with reasoning" over silent pass. - Hashing session ids and email tokens adds a small CPU cost per request (negligible at our scale).
- If the forbidden-identifier list is incomplete, we fail to catch an actual violation. Mitigation: ADR 0002 itself is the list of names; additions go through an ADR amendment, not a silent script edit.
Alternatives considered
Schema-only enforcement
Rejected. A migration is a fleeting moment in review; a CI grep is running continuously.
Database-level users/roles (revoke CREATE on password-like columns)
Rejected as primary mechanism. SQLite doesn't really support this, and we don't want the enforcement to vanish when we switch DB engines.
Pre-commit hook only
Rejected as sole mechanism; accepted as an additional layer. Pre-commit hooks can be bypassed locally, CI cannot.
Runtime assertion
Rejected as primary. Catching at runtime means the bad code shipped. CI-time is preferable.
Compliance checklist
- [x] Invariant #1 defended at schema, code, and CI layers.
- [x] Enforcement is discoverable (the grep list is documentation).
- [x] False-positive path requires an explicit exception with reasoning.
- [x] Session ids and email tokens are hashed at rest even though they are not "credentials" in the invariant sense.
Revisit when
- We change database engines (re-verify FS encryption + constraints).
- We add a new auth surface (mobile app, CLI) — must route through the same single WebAuthn module.
- A real-world incident teaches us a forbidden identifier we missed.