Password recovery is an authentication path with unusual power. A reset link can let its holder replace an account password without presenting the current password, so the token inside that link must be treated as a short-lived credential.
A strong token is not enough by itself. If the same token remains valid after a successful reset, a copied link can be replayed. If two requests can validate the same token before either request marks it used, both may pass. If a database stores raw reset tokens, a database disclosure can turn pending recovery records into immediate account access.
A robust design gives each token a narrow contract: one account, one recovery purpose, one short validity window, and one successful consumption.
Model the token as a credential
A password reset token needs high entropy from a cryptographically secure random source. It must also be independent of user data such as an email address, account ID, timestamp, or password hash.
A practical opaque token can contain 32 random bytes encoded with URL-safe Base64. The application sends the encoded value to the account’s verified recovery channel, but stores only a cryptographic digest of that value.
The database record can contain:
password_reset
id
user_id
token_digest
expires_at
consumed_at
created_atThe raw token belongs only in the delivery link and the request that later presents it. A SHA-256 digest is suitable for lookup when the source token already has strong random entropy. This is different from password storage: human passwords need a slow password hashing function because their input space is weak, while a 256-bit random token already has an impractically large search space.
Generate opaque tokens with a secure random source
In Go, token generation can stay small:
package reset
import (
"crypto/rand"
"encoding/base64"
)
func newToken() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}Do not substitute a general-purpose pseudo-random generator, sequential identifier, UUID variant with insufficient unpredictability, or a signed encoding of predictable account fields. Signing predictable data can protect integrity, but it does not automatically provide secrecy or replay control.
Opaque random tokens also keep authorization state on the server, where expiry and consumption can be enforced directly.
Store a digest instead of the bearer value
Hash the encoded token before persistence:
package reset
import (
"crypto/sha256"
"encoding/hex"
)
func tokenDigest(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}When the reset endpoint receives a token, it computes the same digest and queries by that digest. A database snapshot then exposes digests rather than usable bearer values.
This protection depends on token entropy. It does not make short or predictable tokens safe.
Avoid writing the raw token to application logs, analytics events, tracing attributes, error reports, or support tickets. Query strings are especially prone to appearing in infrastructure logs. After the browser reaches the reset page, a useful pattern is to exchange the URL token for server-side recovery state and redirect to a clean URL.
Enforce a short expiration window
A recovery token should expire soon enough to limit exposure while still allowing normal delivery delays. The exact duration is a product decision, but enforcement belongs on the server.
A valid record needs all relevant conditions:
SELECT id, user_id
FROM password_reset
WHERE token_digest = $1
AND consumed_at IS NULL
AND expires_at > CURRENT_TIMESTAMP;The client must not decide whether a token is fresh. Hidden fields, JavaScript timers, and timestamps embedded in a page are user-controlled inputs from the server’s perspective.
Use a consistent server-side time source. Store timestamps in a format with unambiguous timezone semantics.
Consume the token atomically
A check followed by a separate update creates a race:
request A: token is unused
request B: token is unused
request A: change password
request B: change password
request A: mark token used
request B: mark token usedBoth requests observed valid state before either changed it.
Instead, make consumption itself conditional. PostgreSQL can claim the record with one statement:
UPDATE password_reset
SET consumed_at = CURRENT_TIMESTAMP
WHERE token_digest = $1
AND consumed_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
RETURNING id, user_id;Exactly one concurrent request can change a matching row from unused to consumed. A competing request receives no row and must fail.
This database transition is the core replay defense. Application-level flags, in-memory mutexes, and a preliminary SELECT are not substitutes when multiple application processes can handle requests.
Coordinate consumption with the password update
Atomic token claiming prevents two requests from claiming the same reset record, but the overall workflow also has to handle failure.
A common approach is a database transaction that locks or conditionally consumes the reset record and updates the account password before commit:
BEGIN
conditionally consume reset record
if no row returned:
ROLLBACK
reject request
update account password hash
invalidate applicable recovery records
COMMITIf both the reset record and account credential live in the same transactional database, this gives strong consistency. A failed password update can roll back token consumption instead of leaving the user with a spent token and an unchanged password.
If credential storage spans services, define failure semantics explicitly. Do not pretend a distributed sequence is atomic. A service may instead create a short internal operation state, use idempotent service calls, and record a terminal result so retries cannot produce multiple credential transitions.
Invalidate competing reset requests
Users often request several reset emails. Decide what older tokens should do after a newer request or a successful password change.
A conservative policy is to invalidate all outstanding password reset records for that account after one reset succeeds:
UPDATE password_reset
SET consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP)
WHERE user_id = $1
AND consumed_at IS NULL;Another policy invalidates older records as soon as a new reset token is issued. That reduces the number of active credentials but can frustrate users who click an earlier email after requesting another.
Either policy can be sound if it is deliberate, documented, and enforced server-side. The key property is that a successful password change does not leave forgotten recovery credentials usable without a strong product reason.
Bind the token to one operation
A password reset token should authorize password recovery and nothing else. Do not reuse the same token table and validation path for email verification, invitations, API key rotation, or account deletion unless purpose is part of the validated server-side state.
A shared credential table can include an explicit purpose:
credential_token
token_digest
user_id
purpose
expires_at
consumed_atEvery lookup must then include the expected purpose. A token accepted in one workflow must not silently become valid in another.
Narrow purpose limits damage from routing mistakes and handler reuse.
Keep account discovery out of the request response
The endpoint that starts recovery often receives an email address or username. Different responses for existing and absent accounts can expose account membership.
Prefer a uniform public response such as:
If the account is eligible, recovery instructions will be sent.Keep response status, body shape, and gross timing reasonably similar. Exact timing equality is rarely practical, but avoid obvious branches such as performing expensive work only for existing accounts before returning.
Rate controls are still needed. Uniform responses do not stop an attacker from flooding a known address with recovery messages.
Rate-limit both issuance and redemption
Protect token issuance against message flooding and resource abuse. Useful dimensions can include account, destination, source network, device signals, and broader service capacity.
Protect redemption as well. High-entropy tokens make guessing infeasible, but rate limits still reduce abuse, protect database capacity, and constrain implementation mistakes.
Do not rely on account lockout as the main defense. An attacker should not be able to block a victim’s normal sign-in merely by submitting bad recovery tokens.
Treat the reset page as sensitive browser state
A reset link commonly places the token in a URL. URLs can escape through browser history, copied screenshots, proxy logs, analytics systems, and referrer headers.
Keep third-party resources off the token-bearing page when practical. Set a restrictive referrer policy so navigation does not disclose the reset URL to another origin:
Referrer-Policy: no-referrer
Cache-Control: no-storeServe the entire recovery flow over HTTPS. Do not place the token in fragments and assume that alone solves exposure; application scripts can still read fragments, and the token still exists in browser-visible state.
After initial validation, redirecting to a clean URL backed by a server-side recovery session can reduce repeated exposure. That session needs the same narrow purpose, short lifetime, and one-time completion semantics.
Change the password with normal credential rules
Recovery should feed into the same password hashing policy used by ordinary password changes. Store the new password with the application’s current password hashing function and parameters.
Do not log the new password, compare it against the old password by decrypting stored credentials, or send the new password by email.
After a successful reset, consider invalidating existing sessions according to the application’s risk model. For high-value accounts, revoking existing sessions is often appropriate because a reset can indicate lost control of prior credentials. Some products may preserve selected trusted sessions, but that choice needs explicit security analysis.
Also rotate or revoke credentials that are directly derived from the old password, if any such design exists.
Separate user messages from audit evidence
The public response should reveal little, while internal audit records should capture enough context for investigation.
Useful fields include:
event = password_reset_completed
user_id = 48152
reset_record_id = 993104
source_ip = 192.0.2.24
session_revocation = all
result = successDo not record the raw token or password. Use stable internal identifiers instead.
For failed redemption, record a bounded reason category such as expired, consumed, or not_found only when that distinction is safe for internal telemetry. The public endpoint can return a common failure message.
Handle token comparison and lookup safely
If token digests are indexed and queried by equality, the database performs the lookup. If application code compares fixed-size secret-derived values directly, use a constant-time comparison primitive.
Do not overstate constant-time comparison as a complete side-channel defense. Database access, parsing, network latency, and branching can dominate observable timing. The main controls remain high entropy, expiration, single-use state, and bounded attempts.
Reject malformed token encodings before expensive work. Apply a strict maximum input length so an attacker cannot send arbitrarily large values into hashing, logging, or database paths.
Test the state transition, not only the happy path
Concurrency tests are essential because sequential unit tests can miss replay races.
A useful test starts several requests with the same valid token at once and asserts that exactly one can claim it:
create one valid reset token
start 20 concurrent redemption attempts
wait for all attempts
assert successful claims == 1
assert token is consumed
assert final password state matches the committed operationAlso test these cases:
- expired token
- already consumed token
- malformed token
- unknown token
- token issued for a different purpose
- two active tokens for one account
- database failure during password update
- retry after a transaction rollback
- successful reset followed by replay
- successful reset followed by use of an older reset link
Integration tests should run against the same database engine and transaction pattern used in production. Mocked repositories often cannot reproduce locking and isolation behavior.
Review the full recovery path
A secure reset implementation is a chain of controls. During review, trace one token from creation to final invalidation:
- Generate at least 256 bits of cryptographically secure random data.
- Deliver the bearer value only through the intended recovery channel.
- Persist a digest rather than the raw bearer value.
- Attach the record to one account and one recovery purpose.
- Enforce a short server-side expiration.
- Consume valid state with a conditional atomic database operation.
- Coordinate token consumption and password update with explicit transaction semantics.
- Invalidate other recovery credentials according to a defined policy.
- Keep bearer values and passwords out of logs and telemetry.
- Apply issuance and redemption rate controls.
- Reduce browser and infrastructure exposure of token-bearing URLs.
- Test concurrent redemption and failure rollback.
The central design idea is a state transition, not a string check. A token begins as an unused, unexpired recovery credential. One successful operation moves it into a terminal consumed state. Every later attempt must fail, including requests that arrive at nearly the same moment.