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:
- The submitted code is hashed
- The hash is matched against stored values
- On match, the code record is deleted (single-use enforcement)
- 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)
| Alphabet | Size | Code Length | Entropy (bits) | SHA-256 Safe? |
|---|---|---|---|---|
| A-Z, 0-9 | 36 | 8 | ~41 | No |
| A-Z, 0-9 | 36 | 10 | ~52 | No |
| A-Z, 0-9 | 36 | 12 | ~62 | Yes |
| A-Z, 0-9 | 36 | 16 | ~83 | Yes |
| A-Z (no 0-9) | 26 | 12 | ~56 | No |
| A-Z, a-z, 0-9 | 62 | 10 | ~60 | Borderline |
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 invalidThis 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 timeThe 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
AshRateLimiterextension to limit attempts - Audit log — use an audit log add-on to track and limit failed attempts