ADR-010: Recovery Codes Hashed with bcrypt 

Status

Accepted

Date

2026-03-14

Decision-makers

Sebastian Mendel

Context 

Recovery codes are a fallback authentication mechanism. When a user generates recovery codes, the system stores them and the user saves the plaintext codes offline. At login time, the user enters a code which must be verified against the stored value.

The storage format must be:

  1. One-way (codes cannot be recovered from the database)
  2. Resistant to brute force (if database is compromised)
  3. Verifiable at login time

Options:

A. Plaintext storage — Trivial, but any database leak exposes all codes B. SHA-256 hash — Fast, but vulnerable to rainbow tables / brute force C. bcrypt hash — Intentionally slow, resistant to brute force D. Argon2id hash — Modern, memory-hard, strongest protection

Decision 

Option C: bcrypt with cost factor 12.

Each recovery code is stored as a bcrypt hash in tx_nrpasskeysfe_recovery_code.code_hash.

Code format: XXXX-XXXX (8 alphanumeric characters, grouped for readability). This gives 36^8 ≈ 2.8 trillion possible codes, making brute force infeasible even with fast hashing.

// Generation
$plaintext = $this->generateRandomCode(); // e.g., "A7K2-M9P4"
$hash = password_hash($plaintext, PASSWORD_BCRYPT, ['cost' => 12]);

// Verification
$valid = password_verify($inputCode, $storedHash);
Copied!

Consequences 

Positive:

  • Industry standard for credential storage
  • Cost factor 12 makes brute force impractical ( 250ms per hash on modern hardware)
  • password_hash() / password_verify() are PHP built-ins, no dependencies
  • Automatic salt generation per hash

Negative:

  •  250ms per verification attempt (acceptable for login, rate-limited anyway)
  • Cannot bulk-verify codes (each hash is unique due to salt)
  • bcrypt has 72-byte input limit (not an issue for 8-char codes)

Mitigation:

  • Rate limiting on recovery code verification endpoint
  • Account lockout after N failed recovery code attempts
  • Codes are single-use (marked used_at after successful verification)

Alternatives Considered 

Option A (Plaintext): Unacceptable for any credential storage.

Option B (SHA-256): Too fast. An attacker with the database could brute-force all possible 8-character codes in minutes on a GPU.

Option D (Argon2id): Stronger than bcrypt, but requires libargon2 which may not be available on all PHP installations. bcrypt is universally available and sufficient for recovery codes with rate limiting.