Recovery Code Security

Copy Markdown View Source

This document explains the security model behind the recovery code strategy, including hashing trade-offs, entropy requirements, and verification mechanics.

How Recovery Codes Work

Recovery codes are generated by the system, displayed once to the user, and stored as hashed values. When a user authenticates with a recovery code:

  1. The submitted code is hashed
  2. The hash is matched against stored values
  3. On match, the code record is deleted (single-use enforcement)
  4. The user is authenticated

Plaintext codes are never stored. They exist only in memory during generation and are returned to the caller via action metadata.

Hash Provider Selection

The recovery code strategy supports pluggable hash providers. The choice of provider involves a trade-off between code length and offline attack resistance.

SHA-256 (Default)

SHA-256 is a fast, deterministic hash. Given the same input, it always produces the same output. This enables:

  • Atomic verification — hash the input once, then use a single database query to find and delete the matching record. No race conditions.
  • Near-instant verification — SHA-256 takes microseconds, regardless of how many codes are stored.

The cost: because SHA-256 is fast, an attacker with database access can brute-force short codes quickly. The defence is to use codes with sufficient entropy (the strategy enforces a minimum of 60 bits).

Bcrypt / Argon2

These are slow, salted hashes designed for passwords. Each hash uses a random salt, so the same input produces different output each time. This means:

  • No atomic verification — verification requires loading all stored hashes and checking each one individually.
  • Slower verification — up to ~100ms per hash comparison, so verifying against 10 stored codes may take ~1 second.
  • Strong offline resistance — even short codes take years to brute-force.

The benefit: codes can be shorter (e.g. 8 characters) while remaining secure against offline attacks.

Entropy and Code Length

The strategy calculates the entropy of the configured code format and validates it against the hash provider's declared minimum_entropy().

Entropy formula: code_length * log2(alphabet_size)

AlphabetSizeCode LengthEntropy (bits)SHA-256 Safe?
A-Z, 0-9368~41No
A-Z, 0-93610~52No
A-Z, 0-93612~62Yes
A-Z, 0-93616~83Yes
A-Z (no 0-9)2612~56No
A-Z, a-z, 0-96210~60Borderline

The default configuration (36-character alphabet, 12-character codes) provides ~62 bits of entropy. At SHA-256 speeds (~10 billion hashes/sec on a modern GPU), this would take approximately 15 years to brute-force.

Bcrypt and Argon2 declare minimum_entropy() of 0, meaning any code length is accepted. Their computational cost (~100ms per attempt) provides the protection instead.

Atomic Verification

The strategy uses different verification approaches depending on the hash provider's deterministic?() callback:

Deterministic Hash (SHA-256)

1. Hash the submitted code: SHA256("AB3KMN7QR2XY")  "c95b6120..."
2. DELETE FROM recovery_codes WHERE user_id = ? AND code = "c95b6120..."
3. If a row was deleted  code was valid
4. If no rows deleted  code was invalid

This is a single atomic database operation. Two concurrent requests with the same code cannot both succeed — only one DELETE will find the row.

Non-Deterministic Hash (Bcrypt)

1. SELECT * FROM recovery_codes WHERE user_id = ? FOR UPDATE
2. For each stored hash: bcrypt_verify(submitted_code, stored_hash)
3. If match found: DELETE FROM recovery_codes WHERE id = ?
4. Pad remaining iterations with simulate() for constant time

The FOR UPDATE lock serialises concurrent requests at the database level, preventing two requests from matching the same code. The iteration count is padded to recovery_code_count to prevent timing-based information leakage about how many codes remain.

Brute Force Protection

Brute force protection is mandatory. Without it, an attacker who obtains a session could attempt to guess recovery codes. The three options mirror those available for the TOTP strategy:

  • Custom preparation — implement your own rate limiting or lockout logic
  • Rate limiting — use the AshRateLimiter extension to limit attempts
  • Audit log — use an audit log add-on to track and limit failed attempts