Your Password Reset Link Is a Backdoor: Threat Model It
You can spend a month on Argon2 parameters, TOTP and passkeys, and then let anyone with a link skip all of it. A password reset link is a bearer token that turns "I control this inbox" into "I am this user". It is the weakest door in most login systems, and it is rarely in the threat model.
So let's model it. Not with a framework: just list what the link is, who can touch it, and what each of them gets.
What the link actually is
It is a password that you chose for the user, sent over a channel you do not control, valid for a while, and equivalent to full account takeover if used. That is the whole model. Everything else follows from treating it like a credential rather than like a convenience feature.
The assets are the account, its sessions and any data behind them. The entry points are the request form, the email, the link itself and the page it lands on. Each one has its own failure modes.
Who can reach the token, and how
Walk the token's life from creation to use, and ask who sees it at each hop:
- Generation: a guessable token (seeded PRNG, timestamp, sequential ID) can be predicted without ever seeing the email.
- Database: a plaintext token means a read-only SQL injection or a leaked backup is an account takeover for everyone with a pending reset.
- Email: the mailbox owner, their provider, anyone who has compromised the mailbox, and any mail-forwarding rule an attacker left behind.
- Link scanners: corporate mail gateways fetch every URL in a message before the user sees it.
- The landing page: third-party scripts, analytics and images can receive the token through the
Refererheader. - Logs: the token is in the URL, so it lands in your access logs, your CDN logs and the user's browser history.
That is six places, and only one of them (the mailbox) is the thing people usually think about.
The Host header trap
This one is genuinely sneaky. Many frameworks build the reset URL from the request's Host header. An attacker submits the reset form for the victim's address with Host: evil.example, and the victim receives a perfectly genuine email from your domain containing a link to the attacker's.
The victim clicks, the attacker's server logs the token, and replays it against your real site. The fix is dull: build links from a configured base URL, never from the request.
Detour: why does a link scanner break single-use tokens?
Quick aside, because this one catches people out. Security gateways issue a GET to every link in incoming mail. If your link consumes the token on GET, the scanner uses it before the human does, and the user sees "link expired" with no obvious cause.
The reverse is worse: if the token stays valid after use and the scanner stored the page, you have replayable state somewhere you do not control. So GET shows a form and changes nothing; the token is consumed by the POST that sets the new password.
Generating and consuming the token
The shape I would defend: 256 bits from crypto/rand, store only a hash, and make "check and burn" a single atomic statement so two concurrent requests cannot both win. A SHA-256 hash is fine here (no slow hash needed) because the token is high entropy, not a human password.
package reset
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
)
var ErrInvalidToken = errors.New("invalid or expired reset token")
// New returns the token to email and the hash to store.
func New() (token string, hash []byte, err error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", nil, err
}
sum := sha256.Sum256(raw)
return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil
}
// Consume burns the token and returns the user it belongs to.
func Consume(ctx context.Context, db *sql.DB, token string) (int64, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return 0, ErrInvalidToken
}
sum := sha256.Sum256(raw)
var userID int64
err = db.QueryRowContext(ctx,
`UPDATE password_resets
SET used_at = now()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
RETURNING user_id`, sum[:]).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrInvalidToken
}
return userID, err
}
Because the lookup is by hash, there is no string comparison of secrets to time. The database either finds a row or it does not.
What happens after the reset
Consuming the token is half the job. The other half is cleaning up around it:
- Invalidate every other outstanding reset token for that user.
- Revoke all existing sessions and remember-me cookies, because the reset may well be a response to a compromise.
- Revoke API keys or app passwords if they were created from a session, and think about whether they should survive.
- Email the user that the password changed, to the address on file.
- Log the event with IP and user agent, but never the token.
Step two is the one skipped most often. If an attacker has a live session and the victim resets their password, a system that keeps the old session alive has fixed nothing.
The MFA question
Here is where the backdoor label earns its place. If a reset link lets someone set a new password and log straight in, your second factor is decorative: anyone who owns the inbox walks round it.
You have a few options, and none is free:
- Require the second factor after the reset, before issuing a session. This is the sensible default.
- Treat the reset as proving only email possession, and make sensitive actions ask for MFA again.
- If the user lost their factor too, send them to a slower recovery path with a delay and notifications, not a faster one.
Passkeys help here, but only if "forgot passkey" does not quietly fall back to the same email link. The strength of the account becomes the strength of the weakest recovery route.
Smaller leaks worth closing
- Enumeration: return the same response and similar timing whether or not the address exists. "If that account exists, we have sent an email."
- Referrer: send
Referrer-Policy: no-referreron the reset page and load no third-party resources there. - Expiry: 15 to 60 minutes is plenty; a token valid for days is a standing credential in someone's inbox.
- Rate limits: limit requests per account and per IP, so the form cannot be used to flood a victim's inbox.
- Cache: mark the page
Cache-Control: no-store.
Where the model stops
None of this defends against a compromised mailbox, because that is the premise of the whole mechanism. You can only shrink the window: short expiry, a notification the owner might actually notice, and a delay or extra factor for high-value accounts.
That is a design decision about how much you trust email, and it should be written down rather than inherited from a tutorial. Five minutes of listing who can see the token will usually find something. I would be surprised if it did not.