Recovery codes
Enrolling a TOTP creates a way to be locked out: lose the phone and
the password alone no longer signs anybody in. So completing configure-totp
adds the generate-recovery-codes required action to a subject who holds no
codes already, and that is the page the walkthrough above landed on.
Completing configure-passkey does not: a recovery code is redeemed only at
the TOTP code step, which a passkey skips, so a set issued for one could never
be used. A subject who does already hold codes is not
asked again: a new factor does not invalidate a list they have saved, and
re-issuing would silently retire the copy on their paper.
Two more moments owe it, and one removes it. A sign-in that completes for a
subject who holds a second factor and no unspent code owes the action once,
so a set revoked without a new one asked for does not leave them with no way
back. And removing the last TOTP through the admin API deletes
the whole set with it, spent rows included, and an owed set with it: a code
only stands in for a TOTP code, and a set that outlived it would work again
after a re-enrolment. A passkey is no TOTP, so it neither owes a set nor keeps
one alive. A subject with no TOTP is never accepted a recovery code, in a
tenant that requires a second factor or not. The
admin side, with its transcripts, is
DELETE /subjects/:id/recovery-codes.
Issuing takes the subject's row lock, the one a removal holds, and is refused
while no TOTP is held: a set never outlives the last TOTP, even for a person
regenerating at the instant it is removed. The gate that reads what a subject
owes drops an owed set from somebody with no TOTP, so a correct sign-in goes on
instead of meeting a page that cannot be shown; the one page door that can still
meet the race continues the parked login the same way.
Every render of the page writes one credential audit row,
recovery_codes.issued, in the transaction that stores the hashes — the
forced first set included, and again for each set a re-render issues in
its place. The row carries no code.
Every command and response below continues the same stack, the same
otp-demo tenant and, to begin with, the same auth_session_id.
The one time the codes are shown
The page is the response to the enrolment's own submission, printed in full under Confirming the secret enrols it above.
Ten characters each from Crockford's 32-character base32 alphabet, printed
as two groups of five: 32^10, which is 2^50 per code. The alphabet's
excluded letters — I, L and O — are the ones a reader confuses with
1 and 0, and a code typed with them is folded onto the digits rather
than refused (the replay below does exactly that).
The page is not re-renderable, and that is the whole security property. What the database holds is one credential row per code, carrying an Argon2id hash with the same parameters as a password — nothing anywhere holds the plaintext, so no later page and no administrator can print these again:
docker compose -p odudu-17a -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c \
"SELECT type, left(secret_data->>'hash', 30) AS hash_prefix,
secret_data->>'usedAt' AS used_at
FROM user_credentials WHERE type = 'recovery-code' LIMIT 3;"
type | hash_prefix | used_at
---------------+--------------------------------+---------
recovery-code | $argon2id$v=19$m=19456,t=2,p=1 |
recovery-code | $argon2id$v=19$m=19456,t=2,p=1 |
recovery-code | $argon2id$v=19$m=19456,t=2,p=1 |
(3 rows)
Reloading the page is therefore not a way to see them twice: the render is a
POST result, and repeating it issues a different ten and retires the set
it just displayed. Whatever was on the screen the first time is gone either
way.
That reload is not free, and the cost is worth naming: each one is ten Argon2id hashes (about 40 ms, run together), a delete and ten inserts, and it is repeatable for as long as the action is owed by anybody holding a valid password for the account. It is bounded — a password gets past the first factor, and acknowledging the page ends it — but it is a heavier multiplier than verification's, and the per-account lockout does not reach it: that counts failures, and this path needs a password that works (the roadmap places the self-service fix with P4f). Re-serving the same set instead would cost less and be worse: a second render of a live secret is the one thing this page must not do.
The acknowledgement carries no code back — only the session id. It says the
page was read, and it is what completes the action. With nothing else owed,
it is also what finishes the login: its response is the 302 shown at the
end of Confirming the secret.
Signing in with one, in place of the second factor
A fresh attempt, and this time the authenticator is gone. The request is
parked and the password form rendered exactly as
Path A shows, for a new
auth_session_id, 01a137d3-9df0-76d0-b626-fe71c6edce91. The password step runs as always, and answers
with the code form — the same two fields, because nothing about this
submission says the authenticator is gone:
curl -sS -X POST http://localhost:3094/tenants/otp-demo/login-actions/authenticate \
--data-urlencode 'auth_session_id=01a137d3-9df0-76d0-b626-fe71c6edce91' \
--data-urlencode 'username=ada' \
--data-urlencode 'password=correct-horse-battery'
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Enter your authentication code</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<header class="intro">
<h1>Enter your authentication code</h1>
<p class="context">to continue to <bdi>otp-spa</bdi></p>
<p class="tenant"><bdi>otp-demo</bdi></p>
</header>
<form method="post" action="/tenants/otp-demo/login-actions/authenticate">
<input type="hidden" name="auth_session_id" value="01a137d3-9df0-76d0-b626-fe71c6edce91">
<label>Code from your app <input type="text" name="code" inputmode="numeric" autocomplete="one-time-code"></label>
<label>Or a recovery code <input type="text" name="recovery_code" autocomplete="off"></label>
<button type="submit">Verify</button>
</form>
</main>
</body>
</html>
The second field is what gets filled:
curl -sS -i -X POST http://localhost:3094/tenants/otp-demo/login-actions/authenticate \
--data-urlencode 'auth_session_id=01a137d3-9df0-76d0-b626-fe71c6edce91' \
--data-urlencode 'recovery_code=TKBYN-J8QGH'
HTTP/1.1 302 Found
x-request-id: 01a137d3-9ea5-72a6-b4d1-3d451af1a220
set-cookie: otp-demo-session=01a137d3-9ee4-774c-a1f0-7b51a53740c3:j1qO2TJFYsDsCP29koTcK5BgwzgDtSYtdpuBiFsEXD4; HttpOnly; SameSite=Lax; Path=/
set-cookie: otp-demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
location: http://localhost:8080/callback?code=5I3UGs_lZLJ8IzO16HHeB478eel2UUMkRKTQ7rbbDQs&state=xyz&iss=http%3A%2F%2Flocalhost%3A3094%2Ftenants%2Fotp-demo
content-length: 0
Date: Wed, 14 Oct 2026 00:32:58 GMT
Connection: keep-alive
Keep-Alive: timeout=72
The same success a password-and-code login gets: a session cookie and a code on the redirect. No code from the app was ever submitted, and the login was not asked for one — the OTP step stands down for the rest of an attempt that presented a recovery code, since somebody who reached for the list cannot then produce a code from the authenticator they lost.
The ID token says less about this login than the two-factor one above:
{
"sub": "01a137d3-946e-727f-91c9-3c754e9981c3",
"iss": "http://localhost:3094/tenants/otp-demo",
"aud": "otp-spa",
"iat": 1791937978,
"exp": 1791938278,
"sid": "01a137d3-9ee4-774c-a1f0-7b51a53740c3",
"amr": ["pwd"],
"acr": "2"
}
acr is "2" — two factors ran — and amr names only one of them.
Deliberately: RFC 8176's registry has no value that describes a pre-generated
code off a printed list, and reporting it as otp would mislead a relying party
that reads that value as a live generator. The reading note in the OIDC Core
note
has the full argument, including why an omission is recoverable where a
mislabelling is not.
The same code again is refused as a used code
A third attempt, opened the same way as the one above — a /authorize that
parks the request, then the password, which answers 200 with the code form:
curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
http://localhost:3000/tenants/otp-demo/login-actions/authenticate \
--data-urlencode 'auth_session_id=01a16368-3f9d-7fc3-9628-30596ef3a88e' \
--data-urlencode 'username=ada' \
--data-urlencode 'password=correct-horse-battery'
200
Then the code that already signed somebody in — typed in lower case with a space where the hyphen was:
curl -sS -X POST http://localhost:3195/tenants/otp-demo/login-actions/authenticate \
--data-urlencode 'auth_session_id=01a16368-3f9d-7fc3-9628-30596ef3a88e' \
--data-urlencode 'recovery_code=zqm0z 2nqqk'
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Enter your authentication code</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<header class="intro">
<h1>Enter your authentication code</h1>
<p class="context">to continue to <bdi>otp-spa</bdi></p>
<p class="tenant"><bdi>otp-demo</bdi></p>
</header>
<div class="alert" role="alert" id="form-error">
<ul>
<li id="form-error-0">You have already used that recovery code. Try another one from your list.</li>
</ul>
</div>
<form method="post" action="/tenants/otp-demo/login-actions/authenticate">
<input type="hidden" name="auth_session_id" value="01a16368-3f9d-7fc3-9628-30596ef3a88e">
<label>Code from your app <input type="text" name="code" inputmode="numeric" autocomplete="one-time-code"></label>
<label>Or a recovery code <input type="text" name="recovery_code" autocomplete="off" aria-invalid="true" aria-describedby="form-error-0" autofocus></label>
<button type="submit">Verify</button>
</form>
</main>
</body>
</html>
Two things in one response. The code was typed in lower case with a space where the hyphen was, and it was still recognised as the same code — normalisation folds case, drops anything outside the alphabet, and maps the confusable letters onto digits. And the refusal says the code is spent, which a wrong code does not:
curl -sS -X POST http://localhost:3195/tenants/otp-demo/login-actions/authenticate \
--data-urlencode 'auth_session_id=01a16368-3f9d-7fc3-9628-30596ef3a88e' \
--data-urlencode 'recovery_code=ZZZZZ-ZZZZZ'
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Enter your authentication code</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<header class="intro">
<h1>Enter your authentication code</h1>
<p class="context">to continue to <bdi>otp-spa</bdi></p>
<p class="tenant"><bdi>otp-demo</bdi></p>
</header>
<div class="alert" role="alert" id="form-error">
<ul>
<li id="form-error-0">That code did not work. Check it and try again.</li>
</ul>
</div>
<form method="post" action="/tenants/otp-demo/login-actions/authenticate">
<input type="hidden" name="auth_session_id" value="01a16368-3f9d-7fc3-9628-30596ef3a88e">
<label>Code from your app <input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" aria-invalid="true" aria-describedby="form-error-0" autofocus></label>
<label>Or a recovery code <input type="text" name="recovery_code" autocomplete="off"></label>
<button type="submit">Verify</button>
</form>
</main>
</body>
</html>
The generic message, the one a wrong app code gets: which codes a list holds is not something a wrong guess gets told. The distinction is safe in the other direction because a recovery code is a second factor — by the time one is presented the attempt is already bound to a subject, so "you have used that one" tells that subject about their own credential and nobody else anything at all. It is also the difference between trying the next code and concluding the whole list is worthless.
What makes the refusal possible is that the row survives its use, marked rather than deleted:
docker compose -p odudu-17a -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c \
"SELECT count(*) AS codes, count(secret_data->>'usedAt') AS spent
FROM user_credentials WHERE type = 'recovery-code';"
codes | spent
-------+-------
10 | 1
(1 row)
Ten rows, one spent, nine still usable. Spending one is a single conditional
UPDATE — it sets usedAt only where no usedAt is set — so two
submissions racing the same code serialize on the row and exactly one of
them signs in; the other is refused, because a read-then-write pair is how
both would succeed.
The last code, and the set that replaces it
A list that runs out is the lockout recovery codes exist to prevent, so spending
the last one owes generate-recovery-codes again — in the login that spent it,
not the next one. The section below runs in a tenant of its own, rc8-demo, on
the stack above, with otp_required on: ada enrolled through the same two pages
as above, acknowledged her ten, and then spent them one login at a time, each a
fresh /authorize and the password first. The numbers above belong to
otp-demo and are untouched by it.
The ninth code signs in the way every earlier one did, with one still unspent behind it:
curl -sS -i -X POST http://localhost:3094/tenants/rc8-demo/login-actions/authenticate \
--data-urlencode 'auth_session_id=01a137d4-5cca-713b-903c-d3ce599a19c2' \
--data-urlencode 'recovery_code=ZBA0Z-8N4XS'
HTTP/1.1 302 Found
x-request-id: 01a137d4-5d60-7709-a701-0194a352560d
set-cookie: rc8-demo-session=01a137d4-5e4e-7a32-826d-3d83b7545e54:-Z5iI7GF488P51f-1HRCTMKKXHGGsUyCG8mZF2ES0Ao; HttpOnly; SameSite=Lax; Path=/
set-cookie: rc8-demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
location: http://localhost:8080/callback?code=4ClFExsSD-g_CG_ClPjfnaN3kBwgHoorQMiZiJELTvE&state=xyz&iss=http%3A%2F%2Flocalhost%3A3094%2Ftenants%2Frc8-demo
content-length: 0
Date: Wed, 14 Oct 2026 00:33:47 GMT
Connection: keep-alive
Keep-Alive: timeout=72
The tenth authenticates just as well, and does not redirect:
curl -sS -i -X POST http://localhost:3094/tenants/rc8-demo/login-actions/authenticate \
--data-urlencode 'auth_session_id=01a137d4-5f25-7dd5-b54e-f530dd226d0d' \
--data-urlencode 'recovery_code=W7762-A4QFZ'
The codes themselves are elided here — the set shown above is the one this document prints, and a test holds it to exactly one:
HTTP/1.1 200 OK
x-request-id: 01a137d4-5faf-7d4c-867b-86e075e0ac38
content-type: text/html
content-security-policy: default-src 'none'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; form-action 'self' http://localhost:8080; base-uri 'none'
x-frame-options: DENY
referrer-policy: no-referrer
cache-control: no-store
x-content-type-options: nosniff
content-language: en
content-length: 2041
Date: Wed, 14 Oct 2026 00:33:47 GMT
Connection: keep-alive
Keep-Alive: timeout=72
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Save your recovery codes</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<header class="intro">
<h1>Save your recovery codes</h1>
<p class="tenant"><bdi>rc8-demo</bdi></p>
</header>
<p>Each of these signs you in once, in place of your second factor, if you lose it. <strong>This is the only time they are shown.</strong> Print them or put them in a password manager before you continue — nobody, including an administrator, can show them to you again.</p>
<p>These replace the codes issued to this account before now, which no longer work.</p>
<ol>
…ten of them…
</ol>
<form method="post" action="/tenants/rc8-demo/login-actions/required-action?action=generate-recovery-codes">
<input type="hidden" name="auth_session_id" value="01a137d4-5f25-7dd5-b54e-f530dd226d0d">
<button type="submit">I have saved these codes</button>
</form>
</main>
</body>
</html>
It is the same page the enrolment showed, carrying the extra line it renders when it is replacing a set rather than issuing a first one. Acknowledging it finishes the login, exactly as it did there:
curl -sS -i -X POST \
'http://localhost:3094/tenants/rc8-demo/login-actions/required-action?action=generate-recovery-codes' \
--data-urlencode 'auth_session_id=01a137d4-5f25-7dd5-b54e-f530dd226d0d'
HTTP/1.1 302 Found
x-request-id: 01a137d4-6212-702f-98e3-e260b931935c
set-cookie: rc8-demo-session=01a137d4-6228-72ef-acce-7825c414d1ce:s-AxqiUj8ccFNiWeaAltx-kfFz_ZHL4TTnKs6mn1xvk; HttpOnly; SameSite=Lax; Path=/
set-cookie: rc8-demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
location: http://localhost:8080/callback?code=0X0v6AqfNZkZMfluApYongAIiQ1N0ovrRGryRx984X0&state=xyz&iss=http%3A%2F%2Flocalhost%3A3094%2Ftenants%2Frc8-demo
content-length: 0
Date: Wed, 14 Oct 2026 00:33:48 GMT
Connection: keep-alive
Keep-Alive: timeout=72
What the two responses did to the account, read after the ninth code and again after the tenth:
docker compose -p odudu-17a -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c \
"SELECT count(*) FILTER (WHERE secret_data->>'usedAt' IS NULL) AS unspent
FROM user_credentials
WHERE type = 'recovery-code'
AND tenant_id = (SELECT id FROM tenants WHERE name = 'rc8-demo');"
unspent
---------
1
(1 row)
unspent
---------
10
(1 row)
After the ninth that is 1, and the login redirected. After the tenth it is
10 — the page had already issued the replacement set by the time the query
ran — and user_required_actions held generate-recovery-codes until the
acknowledgement cleared it.
The guard behind this counts unspent rows rather than rows, and that distinction is the whole of it. Spent codes are kept so a replay can be refused as spent, so a subject who has used all ten still holds ten: a guard reading the row count finds them provided for and owes nothing, which is a locked-out account with no page to show it. Keycloak re-presents its own setup at the same moment, for the same reason.
Two ways out that this does not provide, both needing a page this server does not have yet: asking for a fresh set before running out, and a warning as the list gets short. Both are self-service, which is P4f's: an application-initiated action for the fresh set, and the "me" API's count for the warning.
Where the step sits in the flow
docker compose -p odudu-17a -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c \
"SELECT index, authenticator, requirement FROM authentication_executions e
JOIN tenants r ON r.id = e.tenant_id WHERE r.name = 'otp-demo' ORDER BY index;"
index | authenticator | requirement
-------+---------------+-------------
0 | passkey | alternative
1 | password | alternative
2 | otp | conditional
3 | recovery-code | conditional
(4 rows)
Last, and conditional. It is applicable only to a submission that actually
carries a code, which is what keeps it out of the way of the OTP step it
substitutes for rather than competing with it — the same shape as the
passkey step, which is applicable only to a submission carrying an
assertion. A tenant provisioned before this step existed gets the row from
migration 0040_recovery_code_execution.sql, appended so no tenant's
existing indexes shift.