<!--
SPDX-FileCopyrightText: 2026 Alembic Pty Ltd
SPDX-License-Identifier: MIT
-->
# Recovery Code Security
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)`
| 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 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