Changing a password or email address is an account takeover boundary, even when the user is already signed in. A step-up verification gate uses a server-side risk score before either edit, because a stolen cookie should not be enough to rewrite the recovery channel.
Short answer: calculate a short-lived risk score from the session and request, then require a fresh, phishing-resistant factor when the score crosses a policy threshold; deny or hold the change when the signal set is contradictory. Keep the old credential active until the new value is verified, and write an audit event for every branch.
This is a control-flow problem, not a login-page feature. The useful design question is what evidence is still trustworthy at the exact moment an account is edited.
Start with the irreversible action
Treat password and email changes as separate commands with the same gate. A password change invalidates existing sessions; an email change can hand an attacker the reset channel. The endpoint should therefore accept an explicit intent (change_password or change_email) and a request identifier, but it must not accept a client-supplied risk result.
I model the decision as allow, step_up, or deny. The score is an input to policy, never a verdict on its own. A recent WebAuthn assertion may lower risk; a new device, impossible travel, an absent recent MFA timestamp, or a burst of attempts raises it. Signals need expiry times and provenance so an old “trusted device” flag cannot silently bless a new network.
The state transition is deliberately boring:
- Load the session and account risk snapshot.
- Recompute risk on the server and bind the result to the intent and request ID.
- If step-up is required, issue a one-time challenge with a five-minute lifetime.
- Verify the challenge, then re-evaluate that it still matches the same session and intent.
- Commit the change and revoke sessions according to policy.
Do not mutate the password or email before step four. That ordering makes retries safe and gives reconciliation a clear before-and-after record.
Keep it boring.
How should a risk score gate password or email changes?
Use thresholds that describe actions, rather than pretending the number is a universal probability. For example, a service can map a score below 30 to allow, 30–69 to step_up, and 70 or higher to deny, while keeping the component signals in the audit record. Those cutoffs are policy choices that require replaying representative traffic and reviewing false positives; your mileage may vary.
Here is a compact Go policy function. It has no network calls, which makes boundary tests deterministic.
package auth
type Decision string
const (
Allow Decision = "allow"
StepUp Decision = "step_up"
Deny Decision = "deny"
)
type Signals struct {
Score int
RecentMFA bool
NewDevice bool
RecoveryEdit bool
}
func Decide(s Signals) Decision {
if s.Score >= 70 || (s.RecoveryEdit && s.NewDevice && !s.RecentMFA) {
return Deny
}
if s.Score >= 30 || s.RecoveryEdit || !s.RecentMFA {
return StepUp
}
return Allow
}
The important detail is the second deny condition: a high-impact recovery edit from a new device without recent MFA should not be rescued by an accidentally low aggregate score. Conversely, allow still needs CSRF protection, rate limits, and a session that has not expired. Risk scoring complements those controls; it does not replace them.
Make the challenge and commit exactly once
The challenge record should contain a hash of the session ID, intent, request ID, factor type, creation time, expiry, and a consumed flag. Store only a verifier for a code, never the code itself. A successful verification consumes the record atomically. The commit then uses an idempotency key equal to the request ID, so a client retry returns the original result instead of applying the mutation twice.
For an email change, send a confirmation link to the new address and keep the old address as the recovery destination until confirmation. For a password change, require the new password to pass the service's password policy, hash it with a modern password hashing function, and revoke other sessions after the write. OWASP's authentication guidance also recommends reauthentication for sensitive account changes and careful handling of failure responses: do not reveal whether an email belongs to an account.
Audit events should be append-only and include actor, subject, intent, decision, score, signal IDs, challenge ID, request ID, outcome, and timestamp. Never put passwords, raw tokens, or full email addresses in the event. A ledger-minded rule helps here: an event may be retried, but it must not be rewritten. If the database write succeeds and the notification fails, mark the notification as pending and reconcile it; do not roll back an already committed credential without a compensating, authenticated action. During an incident review, this record should let an engineer reconstruct the exact policy version, signal freshness, challenge result, and database transaction outcome without asking the user to repeat the action or trusting an overwritten log line. That is the difference between an audit trail and a debug print.
Test the uncomfortable paths
Unit tests cover threshold boundaries (29, 30, 69, 70), expired challenges, wrong intent, replayed request IDs, and concurrent consumption. Property tests should assert that a consumed challenge can never produce a second successful commit. Integration tests exercise session revocation and notification reconciliation with a real transactional database.
Operationally, alert on repeated step_up and deny decisions by account, device, and network range, but avoid turning a single noisy signal into an automatic ban. Log decision latency and challenge completion rate. A sudden completion drop can indicate a broken enrollment flow; a sudden score spike can indicate an abuse campaign. Both deserve investigation before changing thresholds.
There is a real trade-off. A strict gate protects the recovery channel but can strand legitimate users who lost their second factor. This design is not suitable when you have no staffed recovery process or durable identity proof; use a narrower set of editable fields and a delayed review queue instead. Stick with a lighter gate for low-impact profile fields, and reserve deny decisions for combinations that your incident team can explain.
Roll out with a reversible policy
Ship the scorer in shadow mode first: calculate and record decisions while the existing flow remains authoritative. Compare predicted step-ups with support tickets and confirmed abuse, then tune signal expiry and thresholds. Enable enforcement for email changes before passwords if your reset channel is the larger exposure, or reverse that order when credential stuffing is your dominant incident pattern.
Version the policy alongside the audit schema. Include the policy version in every event so a later investigation can reproduce why a request was allowed. Keep a kill switch that disables enforcement without deleting evidence, and rehearse its use; a control that cannot be safely relaxed during an incident becomes a liability.
The goal is not a perfect score. It is a bounded, reviewable decision that makes an attacker present fresh proof at the moment it matters, while giving an honest operator enough evidence to reconcile every change.
Top comments (0)