Send a single-use reset link carrying a random token of at least 128 bits, store only its hash with the account, purpose and a short expiry, and redeem it in one transaction that also changes the password and revokes existing sessions. Build the link from configuration rather than the Host header, answer the same way whether or not the account exists, and never let a support call or a matching email bypass the factors the account already has.
- Recovery is an alternate login path, and attackers use the weakest path available.
- Reset tokens: at least 128 random bits, one purpose, single use, stored as a hash, short expiry.
- Consume the token and change the credential in one transaction, then revoke sessions and notify the user.
- Give the same response and timing for unknown accounts, and build reset URLs from configuration, never the
Hostheader. - Help-desk resets need verification through enrolled factors, approvals for privileged accounts, cooldowns and alerts.
- Link an external identity only after proving control of both accounts; a matching email is not proof.
| Setting | Value | Note | Source |
|---|---|---|---|
| Reset token | At least 128 bits from a CSPRNG (this guide uses 256) | For example secrets.token_urlsafe(32) in Python. | ASVS 5.0 V11.5.1; OWASP Forgot Password Cheat Sheet |
| Storage | SHA-256 hash of the token, with account, purpose, expiry and used time | A fast hash is enough for a 256-bit random value. | OWASP Forgot Password Cheat Sheet |
| Lifetime | Short, such as 30 minutes for an email link | NIST maximums for issued recovery codes: 24 hours by email, 10 minutes by SMS or voice. | NIST SP 800-63B-4 section 4.2.1.2 |
| Reset link | Built from the configured origin; reset page sends Referrer-Policy: no-referrer | A Host-header-derived link can send the token to an attacker. | OWASP Forgot Password Cheat Sheet |
| After a reset | Revoke sessions and refresh grants; notify existing contact points | Do not bypass enabled MFA during the reset. | ASVS 5.0 V6.4.3, V6.3.7 and V7.4.3 |
| Saved recovery codes | At least 64 random bits each, stored hashed, single use | Issue a replacement after each use and notify the user. | NIST SP 800-63B-4 section 4.2.1.1 |
- Request resets for an existing and an unknown account and compare status, body, headers and timing: they must match.
- Redeem one token twice, concurrently, and at its exact expiry time: exactly one redemption may succeed, before expiry only.
- Send a reset request with a forged
Hostheader and confirm the emailed link still uses the configured origin. - After a reset, confirm that sessions on other devices end and that a notification reaches the existing address.
- Sign in through a new identity provider whose email matches an existing account: no link may be created without proof of both accounts.
- Applies to
Self-service recovery, support-desk recovery and account linking. An email address match alone is not sufficient proof to merge identities.
- Part of
- Learning path: Identity through the whole lifecycle (step 6 of 14). Workflows: Fix a finding, Secure a feature, Test an application
Learning goals and prerequisites
What you will learn
- Treat recovery and linking as grants of authority.
- Design atomic single-use proof consumption.
- Separate stable external identity from email matching.
Before you start
Developers who build password reset, recovery and account linking, and support and security teams who handle account takeover requests.
- Identify the authoritative account, existing authenticators and session revocation paths.
Refresh first: Session management: cookies, timeouts, rotation and revocation
Go to the guided practiceRecovery exists because normal authenticators may be lost. That makes it a deliberate alternate path to control of an account. An attacker does not need to defeat the strongest login method if a weaker reset, support, or linking path can install a new credential instead.
Draw the authority transition: identify the target account, prove control of an approved recovery factor, authorize a specific recovery action, replace or bind credentials, revoke appropriate prior authority, and notify through established channels. Keep account discovery separate from the proof. Knowing an email address or personal biographical fact is not proof of current account control.
Account linking creates a different but related transition: a new external identity becomes a way to enter an existing local account. The durable external identifier is generally the issuer and subject pair under the chosen federation protocol. A matching email string is useful discovery information but does not, by itself, authorize that transition.
A reset request should produce a response that does not disclose whether the account exists, in its body, status, headers or timing. Apply abuse controls without creating a simple account lockout weapon. Deliver a single-use token of at least 128 random bits through the established recovery channel, and keep only its hash in the recovery store. Build the link from the configured application origin: a link built from the request’s Host header lets an attacker who submits a reset with a forged host receive the victim’s token.
Store the token’s account, purpose, expiry, and consumption state. A password-reset token must not become an email-change or account-linking token merely because every handler accepts the same token syntax. Compare the requested action with the stored purpose and use server-selected account state.
The reset operation should atomically verify and consume the proof while committing the permitted change. A read-then-write sequence can allow two concurrent requests to reuse one token. Define what happens if token consumption succeeds but the password update fails; a database transaction or explicit recoverable state machine prevents ambiguous partial completion.
Do not automatically treat a reset link visit as a complete login unless the intended policy supports that and has been carefully designed. Link scanners may visit URLs before the user does. Keep irreversible consumption associated with an intentional protected action rather than an incidental page fetch.
After a successful reset, invalidate relevant sessions, refresh grants, and pending recovery or enrollment transactions according to the application policy. Notifications should go to established contact points and contain enough context to detect an unexpected change without exposing the new credential. Recovery may warrant a temporary restriction on high-impact actions. The session guide at /domains/identity-crypto/authentication/session-lifecycle/ covers revoking sessions server-side.
Creation stores only a hash; redemption is one conditional update that checks purpose, expiry and unused state and returns the account in the same statement, so two concurrent requests cannot both succeed. The password change and a session-version bump commit in the same transaction. Tested with Python 3.14 and SQLite 3.53 (RETURNING needs SQLite 3.35 or later), including eight concurrent redemptions with exactly one winner and the exact expiry boundary. The password reset token lab at /labs/recovery/ practices the same race.
import hashlib
import secrets
import sqlite3
import time
RESET_URL = "https://app.example/reset?token=" # from configuration, never from the Host header
TTL_SECONDS = 30 * 60
def digest(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest() # store only this; the token is never kept
def create_reset(db: sqlite3.Connection, account_id: int) -> str:
token = secrets.token_urlsafe(32) # 256 bits from the operating system CSPRNG
with db:
db.execute(
"INSERT INTO resets (token_hash, account_id, purpose, expires_at) VALUES (?, ?, 'password-reset', ?)",
(digest(token), account_id, int(time.time()) + TTL_SECONDS),
)
return RESET_URL + token # send to the address already on the account
def redeem_reset(db: sqlite3.Connection, token: str, new_password_hash: str) -> bool:
now = int(time.time())
with db: # consume the token and change the credential in one transaction
row = db.execute(
"UPDATE resets SET used_at = ? WHERE token_hash = ? AND purpose = 'password-reset' "
"AND used_at IS NULL AND expires_at > ? RETURNING account_id",
(now, digest(token), now),
).fetchone()
if row is None:
return False # unknown, used, expired or wrong purpose: one generic answer
db.execute(
"UPDATE users SET pw_hash = ?, session_version = session_version + 1 WHERE id = ?",
(new_password_hash, row[0]), # the version bump signs out every existing session
)
return TrueFor an authenticated local user adding a federation identity, require sufficiently recent local proof and a fresh correctly bound login with the new issuer. Attach the resulting validated issuer/subject, checked as in /domains/identity-crypto/authentication/oauth-oidc-login/, to the server-selected local account. If that external identity is already linked elsewhere, follow an explicit conflict process; do not silently merge accounts.
An unauthenticated sign-in that returns an email matching an existing account should not automatically attach itself to that account. Email verification semantics differ across issuers, domains can change hands, and address reuse can occur. Even a well-verified email does not establish all historical account ownership.
Unlinking also needs lifecycle reasoning. Removing the last usable authentication method can strand the user; removing a suspicious method while leaving attacker-created sessions alive may fail to remove the attacker. Show the remaining methods and verify control before committing a risky change.
CISA’s advisory on Scattered Spider (AA23-320A, updated July 2025) describes attackers who research an employee, call the IT help desk to have passwords and MFA reset or moved to their device, swap the victim’s SIM, and send repeated push prompts until one is accepted. CISA recommends phishing-resistant MFA such as FIDO2 or PKI, which resists all three. The help desk then needs procedures an attacker cannot talk past:
- Verify the caller through a factor already enrolled on the account, or a call back to a number on file, never through data an attacker can look up.
- Require a second approver, such as the manager, for privileged and administrative accounts.
- Send a time-limited recovery link instead of setting or reading out a password, and do not bypass enrolled MFA.
- After losing every factor, repeat identity proofing at the account’s original level before binding new authenticators.
- Treat a new phone number as a new authenticator, and check SIM-swap and number-porting signals before sending SMS codes.
- Alert the user and the security team on every factor reset, and delay high-impact actions for a cooling-off period.
Ordinary valid reset completion should change exactly the intended credential. Reuse, expiry, wrong purpose, wrong account, and concurrent double submission must fail without a second change. A generic error message alone is insufficient if another response header or behavior reveals the account.
Test linking with matching emails but different issuer/subject pairs; the result must follow the explicit linking proof, not string equality. Test an already-linked external identity and an attempt to remove the final authentication method. Capture resulting sessions and credentials so the evidence shows who retains authority after each transition.
Guided practice
A code review lands on the password-reset handler below. Find every flaw, compute how long the token takes to guess, and write the fixed version and its tests. Then decide the account-linking request at the end. Produce your answers before reading the worked ones.
Supplied scenario material
import random
def create_reset(email):
user = db.find_user(email)
if user is None:
return "No account with that email", 404
token = str(random.randint(100000, 999999))
db.execute("INSERT INTO resets (token, user_id, created) VALUES (?, ?, ?)", (token, user.id, now()))
send_mail(user.email, f"https://{request.headers['Host']}/reset?token={token}")
return "Sent", 200
def redeem(token, new_password):
row = db.execute("SELECT user_id, created, used FROM resets WHERE token = ?", (token,)).fetchone()
if row and not row.used and now() - row.created < timedelta(hours=24):
db.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (hash_password(new_password), row.user_id))
db.execute("UPDATE resets SET used = 1 WHERE token = ?", (token,))
return True
return False
# Separate request: a login from https://new-id.example (sub=777, email=alice@example.test, verified)
# arrives; local account alice has the same email and no linking transaction is in progress.1. List the flaws
Name every security flaw in
create_resetandredeem.Expected result and reasoning
Account enumeration:
404versus200says which emails exist. A guessable token: six digits fromrandom, which is not a CSPRNG. Plaintext tokens in the database. Host-header poisoning: the link uses the request'sHostheader, so an attacker who submits the reset withHost: evil.examplereceives the victim's token when the victim clicks. A 24-hour lifetime. A read-then-write race: two concurrent requests can both pass the check and both change the password. No purpose field, no session revocation and no notification after the change.2. Compute the guessing time
Compute how long an attacker needs to try every possible token at 100 requests per second with no attempt limit, and compare with a 256-bit token.
Shellpython3 -c 'print(900_000 / 100, "seconds"); import math; print(round(math.log2(900_000), 1), "bits")'Expected result and reasoning
random.randint(100000, 999999)has 900,000 values, about 19.8 bits: 9,000 seconds, two and a half hours, to try them all, half that on average, and far less if many resets are outstanding at once.secrets.token_urlsafe(32)gives 256 bits, which no rate of guessing can exhaust; ASVS asks for at least 128 bits.3. Write the fix
Rewrite both functions.
Expected result and reasoning
Always answer "If the account exists, we sent a link" with the same status and similar timing. Generate
secrets.token_urlsafe(32), store only its SHA-256 with account, purposepassword-resetand an expiry of about 30 minutes, and build the link from a configured origin. Redeem with one conditional update that checks hash, purpose, unused and unexpired state and returns the account (UPDATE ... SET used_at = ? WHERE token_hash = ? AND purpose = 'password-reset' AND used_at IS NULL AND expires_at > ? RETURNING account_id), then change the password and bump the session version in the same transaction, and notify the account's existing address. The Python example on this page is a tested version.4. Write the tests
List the tests that prove the fix.
Expected result and reasoning
Same response for known and unknown emails; a forged
Hostheader does not change the emailed link; the stored row holds no usable token; eight concurrent redemptions of one token produce exactly one success; a token at exactly its expiry fails; a reset token is refused by the email-change handler; and other sessions stop working after the reset.5. Decide the linking request
Decide whether the new-id.example login may be attached to alice's account because the verified emails match.
Expected result and reasoning
No. A matching email, even a verified one, proves control of a mailbox at some time, not of alice's account; anyone who can get alice's address onto an account at another provider, for example through an unverified, editable email attribute as in the 2023 nOAuth cases, would take her account over. Require alice to authenticate to the existing account and then complete a fresh login at new-id.example in the same linking transaction before storing
(https://new-id.example, 777)against her account.
The handler is a sketch of a common pattern, not a specific framework's code; the lab on this page practices the redemption race against real concurrent requests.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
Password reset token lab: Make a recovery credential single-use
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP Forgot Password Cheat Sheet
- NIST SP 800-63B-4: account recovery. SP 800-63B-4 (July 2025).
- CISA advisory AA23-320A: Scattered Spider. 16 November 2023, updated 29 July 2025.
- OWASP ASVS 5.0: V6 Authentication. Version 5.0.0 (May 2025).
- IETF RFC 9700: Best Current Practice for OAuth 2.0 Security. January 2025 (BCP 240).
Reference information
- SDLC phase
- Development
- ASVS 5.0.0 requirements
- v5.0.0-6.3.7 · v5.0.0-6.3.8 · v5.0.0-6.4.3 · v5.0.0-6.4.4 · v5.0.0-7.4.3 · v5.0.0-11.5.1
- CAPEC attack patterns