---
title: "ADR-010: Recovery Codes Hashed with bcrypt"
manual: "Passkeys Frontend Authentication"
version: "2.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-passkeys-fe:adr-010@2.0"
source: "Adr/Adr010RecoveryCodesBcryptHashed.rst"
rendered: "2026-09-29T22:18:36+00:00"
---

# ADR-010: Recovery Codes Hashed with bcrypt {#adr-010}

-   *Status:* Accepted
-   *Date:* 2026-03-14
-   *Decision-makers:* Sebastian Mendel

## Context {#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)
1.  Resistant to brute force (if database is compromised)
1.  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 {#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.

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

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

## Consequences {#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 {#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.
