Skip to main content

DELETE /subjects/:id/recovery-codes

Requires manage-users. Revokes every recovery code the subject holds, spent ones with them, so none signs in again — the answer to a printed list somebody else may have read. A code carries no id of its own (GET /subjects/:id/credentials says why), so the set is the only thing there is to remove. It answers 204 whether or not any were held, and its subject.recovery_codes_revoke audit row says how many went in detail.revoked.

A revoked set is replaced unless the request says otherwise. While the subject still holds a TOTP enrolment, the request adds the generate-recovery-codes required action in the same transaction, so their next sign-in ends on the page that issues a fresh set; ?require_new_set=false leaves them with none until PUT /subjects/:id/required-actions or the next sign-in asks (below). A subject with no TOTP is never asked, since a set would serve nothing. The audit row's detail.new_set_required says which it was. Any value of require_new_set other than true or false is refused with 400 before anything changes. The subject is mailed at the address on file when any code was revoked, saying the old codes no longer work and, if one is asked, that a new set will be; there is no mail for a revoke that found nothing, and none for a subject with no address.

A sign-in that completes with a TOTP and no unspent code also owes a set, once: it adds generate-recovery-codes, which saving a set clears, so it does not repeat. That covers a subject whose set was revoked with require_new_set=false, and one enrolled before codes existed. It is one aggregate query on the sign-in's success path, and never runs for a subject with no TOTP. Revoking the set because the last TOTP was removed is DELETE /subjects/:id/credentials/:credentialId's doing, not this endpoint's.

An unknown subject, one in another tenant, or one with no users row answers 404, and a subject holding an admin capability the caller does not is refused with 403 (the target ceiling).

Described at the top of this page, on ines in lockout-demo — the subject GET /subjects/:id/lockout below also uses. Its required actions first, for the ETag the PUT sends:

INES=http://localhost:3080/admin/tenants/lockout-demo/subjects/01a0ec85-0c4c-7ab4-821c-a4fad1d15482
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" "$INES/required-actions" | grep -iE '^etag|^\{'
etag: "7d357b0ef1f85ba71c5ccebb6671b0c34f4b3950f5b21d2af7b4a3d4e9dcd570"
{"actions":[]}

The PUT owes a set, the sign-in that follows ends on the page that issues it (its title printed, by signin from that section, and none of the codes), and the credentials list then counts ten:

curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-H 'if-match: "7d357b0ef1f85ba71c5ccebb6671b0c34f4b3950f5b21d2af7b4a3d4e9dcd570"' \
-d '{"actions":["generate-recovery-codes"]}' "$INES/required-actions"
signin 'correct horse battery staple'
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" "$INES/credentials"
{"actions":["generate-recovery-codes"]}
HTTP/1.1 200 OK
<title>Save your recovery codes</title>
{"items":[{"id":"01a0ec85-0c6d-75c9-a720-81d3b70c0811","type":"password","created_at":"2026-09-29T09:35:37.542Z","expired":false},{"type":"recovery-code","created_at":"2026-09-29T09:36:10.866Z","recovery_code_count":10}]}

The rest is on the tenant the credential section above names (recovery-life, a public client life-app with https://app.example/callback), on olga and noor, each seeded with seed user and then given as the database owner a TOTP credential and ten recovery-code rows. The id variables are their subject ids, OLGA=01a1554b-be61-7ac8-b5d5-5cedc64a4492 and NOOR=01a1554b-fca4-73b7-ad55-6a4150567782. olga is revoked with the default: her required-actions first (empty, with the ETag), the revoke, what is owed after, the credentials (the TOTP stays, no recovery-code entry), the audit row for this action, and her mail:

S=http://localhost:3187/admin/tenants/recovery-life/subjects
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$OLGA/required-actions" | grep -iE '^etag|^\{'
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$OLGA/recovery-codes"
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$OLGA/required-actions"; echo
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$OLGA/credentials"; echo
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3187/admin/tenants/recovery-life/audit?resource_type=subject&resource_id=$OLGA&action=subject.recovery_codes_revoke" |
python3 -c "
import json,sys
for r in json.load(sys.stdin)['items']: print(json.dumps({k:r[k] for k in ('action','outcome','detail')}))"
docker compose -p odudu-p4g-recovery exec -T postgres psql -U odudu -d odudu -tA -c \
"select to_address, subject, body_text from email_outbox where tenant_id = (select id from tenants where name = 'recovery-life') and to_address = 'olga@example.test'"
etag: "7d357b0ef1f85ba71c5ccebb6671b0c34f4b3950f5b21d2af7b4a3d4e9dcd570"
{"actions":[]}
HTTP/1.1 204 No Content
x-request-id: 01a1554c-4c0a-7fe3-a0ff-cef22c539c0d
cache-control: no-store
Date: Mon, 19 Oct 2026 17:53:46 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"actions":["generate-recovery-codes"]}
{"items":[{"id":"01a1554b-be82-7e8a-ab63-1ecfd873f6b7","type":"password","created_at":"2026-10-19T17:53:09.725Z","expired":false},{"id":"f10d2fd7-1c2b-4095-b53c-5e94d7ad2f26","type":"totp","created_at":"2026-10-19T17:53:26.695Z"}]}
{"action": "subject.recovery_codes_revoke", "outcome": "allowed", "detail": {"revoked": 10, "new_set_required": true}}
olga@example.test|Your recovery-life recovery codes were revoked|An administrator revoked the recovery codes on your ⁨recovery-life⁩ account. The old codes no longer work.

You will be asked to save a new set the next time you sign in.

If you did not expect this, contact your administrator.

--
You received this because the recovery codes on your account at ⁨recovery-life⁩ were changed.
© 2026 Odudu · Powered by odudu

The mail above, for olga, is from the same setup, with olga seeded with seed user and given a TOTP credential and ten recovery-code rows as ines was.

noor is revoked with require_new_set=false, after a value that is neither true nor false was refused and the ten codes counted to show it changed nothing. Nothing is owed after, and the audit row says so. Then noor signs in, the password and then the code from the authenticator app. signin is the helper the lockout section below defines, pointed at this tenant and client and taking, after the username, the name and value of a second field to post once the password has been taken; totp.py is a five-line RFC 6238 generator over the secret the TOTP row was seeded with. The sign-in ends on the page that issues a set, and the set is now owed:

AUTHORIZE='http://localhost:3187/tenants/recovery-life/protocol/openid-connect/auth?response_type=code&client_id=life-app&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&scope=openid&state=xyz&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256'
signin() {
sid=$(curl -sS "$AUTHORIZE" | sed -n 's/.*name="auth_session_id" value="\([^"]*\)".*/\1/p' | head -1)
curl -sS -D - -o body.html --data-urlencode "auth_session_id=$sid" --data-urlencode "username=$1" \
--data-urlencode "password=correct horse battery staple" \
http://localhost:3187/tenants/recovery-life/login-actions/authenticate | grep -iE '^(HTTP)' | tr -d '\r'
grep -o '<title>[^<]*</title>' body.html || true
if [ -n "$2" ]; then
curl -sS -D - -o body.html --data-urlencode "auth_session_id=$sid" --data-urlencode "$2=$3" \
http://localhost:3187/tenants/recovery-life/login-actions/authenticate |
grep -iE '^(HTTP|location)' | cut -c1-90 | tr -d '\r'
grep -o '<title>[^<]*</title>' body.html || true
fi
}
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$NOOR/recovery-codes?require_new_set=maybe"
docker compose -p odudu-p4g-recovery exec -T postgres psql -U odudu -d odudu -tA -c \
"select count(*) from user_credentials where subject_id = '$NOOR' and type = 'recovery-code'"
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$NOOR/recovery-codes?require_new_set=false"
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$NOOR/required-actions"; echo
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3187/admin/tenants/recovery-life/audit?resource_type=subject&resource_id=$NOOR&action=subject.recovery_codes_revoke" |
python3 -c "
import json,sys
for r in json.load(sys.stdin)['items']: print(json.dumps({k:r[k] for k in ('action','outcome','detail')}))"
signin noor code "$(python3 totp.py GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ)"
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$NOOR/required-actions"; echo

The 400 is cut to the lines that identify it: its x-request-id, cache-control, date and keep-alive lines are dropped with grep -v, which the commands above do not show. The status lines and titles are what signin prints, status and title after the password, then status, any location and title after the code. The wrong value is refused by the router's own query check, whose message is its validator's:

HTTP/1.1 400 Bad Request
content-type: application/problem+json; charset=utf-8
content-length: 276

{"type":"about:blank","title":"Error","status":400,"detail":"querystring/require_new_set must be equal to one of the allowed values","errors":[{"path":"require_new_set","message":"must be equal to one of the allowed values"}],"instance":"01a1554c-6f30-7642-b969-feb18871fdc9"}
10
HTTP/1.1 204 No Content
{"actions":[]}
{"action": "subject.recovery_codes_revoke", "outcome": "allowed", "detail": {"revoked": 10, "new_set_required": false}}
HTTP/1.1 200 OK
<title>Enter your authentication code</title>
HTTP/1.1 200 OK
<title>Save your recovery codes</title>
{"actions":["generate-recovery-codes"]}

A revoke that asks for a new set, followed by the removal of the last TOTP, leaves nothing owed, and the next sign-in is not refused for a page that cannot be shown. On a fresh volume (the same names as above, so the ids are that run's: olga is 01a155ef-96aa-7aff-86c2-84b67d1c3a3d, her TOTP row d3c306d7-002d-487c-8d4b-acdd5817707d), seeded with a TOTP and ten code rows as the database owner. AUTHORIZE is the helper's own URL for this tenant and client:

S=http://localhost:3187/admin/tenants/recovery-life/subjects
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$OLGA/recovery-codes"
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$OLGA/required-actions"; echo
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$OLGA/credentials/$TOTP"
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" "$S/$OLGA/required-actions"; echo
sid=$(curl -sS "$AUTHORIZE" | sed -n 's/.*name="auth_session_id" value="\([^"]*\)".*/\1/p' | head -1)
curl -sS -D - -o body.html --data-urlencode "auth_session_id=$sid" --data-urlencode username=olga \
--data-urlencode "password=correct horse battery staple" \
http://localhost:3187/tenants/recovery-life/login-actions/authenticate

The two DELETEs were piped through grep -E '^HTTP' and the sign-in through grep -iE '^(HTTP|location)' | cut -c1-70, none of which the commands above show:

HTTP/1.1 204 No Content
{"actions":["generate-recovery-codes"]}
HTTP/1.1 204 No Content
{"actions":[]}
HTTP/1.1 302 Found
location: https://app.example/callback?code=WZ04g9i_owPX2Tov5E4GvsHbZ1