A forgotten password page is where an edtech creator account becomes an incident.
Short answer: use a single-use, short-lived reset token, keep an explicit inventory of every identity and session, and revoke sessions only after the password write commits; this gives auditors a trace without forcing every legitimate teacher through a support queue.
At 02:13, the useful page is not 'reset endpoint is slow.' It is '12 reset requests for one creator, no successful completion in 15 minutes.' The on-call sees a creator_id, a request correlation ID, token state, and the count of active sessions. That context makes the next action obvious: freeze token issuance for that identity, inspect the audit trail, and contact the account owner through a separately verified channel.
What should password reset, identity inventory, and session cleanup protect?
Start with the threat model, not the form. A creator can have a login email, a school-managed identity, a recovery email, a device session, and an API token used by a publishing tool. These are different credentials with different blast radii. An identity inventory records their type, verification time, last use, and revocation status. It should not store reset secrets or a full token value.
OWASP recommends generic responses for account-recovery requests so an attacker cannot enumerate accounts. The request path should create a random, single-use token, store only a hash and an expiry, and send a message through the already verified recovery channel. The completion path compares the hash, checks expiry and use state, updates the password with a modern password-hashing configuration, marks the token consumed, and writes an audit event. Those writes need one transaction boundary.
Here is the small part I keep in a runbook because it is easy to get subtly wrong:
type ResetAttempt struct {
AccountID string
TokenHash []byte
ExpiresAt time.Time
Used bool
}
func completeReset(ctx context.Context, repo Repository, accountID string, presented []byte, newPassword []byte) error {
attempt, err := repo.LockUnusedAttempt(ctx, accountID)
if err != nil {
return err
}
if attempt.Used || time.Now().After(attempt.ExpiresAt) || subtle.ConstantTimeCompare(hashToken(presented), attempt.TokenHash) != 1 {
return ErrInvalidReset
}
hash, err := bcrypt.GenerateFromPassword(newPassword, bcrypt.DefaultCost)
if err != nil {
return err
}
return repo.CommitPasswordAndRevoke(ctx, accountID, hash, attempt)
}
The comparison expression in production should be written in the straightforward form subtle.ConstantTimeCompare(hashToken(presented), attempt.TokenHash) != 1; the longer line above is deliberately the review cue I want a second engineer to catch before merge. A test must prove that replaying the same token returns ErrInvalidReset, while an expired token produces the same external response as an unknown account.
Work backward from the page to the earlier signal
The page fired because support tickets arrived before telemetry did. Work backward: a reset request has a request ID; token issuance, delivery handoff, token redemption, password commit, and session revocation all carry it. Emit counters for each transition and a histogram for completion latency. Add dimensions for account class and recovery channel, but never put an email address or token in labels.
A useful alert is a ratio, not a raw count: invalid or expired redemption attempts divided by issued tokens, evaluated per account and over a rolling window. A sudden rise can indicate phishing or a broken mail path. I am not sure a universal threshold exists; your mileage may vary with school-term traffic, so tune it against a week of ordinary resets and document the chosen baseline.
The instrumentation change should answer three questions in one dashboard: did we issue a token, did the owner redeem it, and did all expected sessions disappear? A missing edge is actionable. For example, a successful password commit with zero revocation events is a security alert even if the HTTP request returned 200.
Make cleanup idempotent and inspectable
Session cleanup is a set operation. Mark the password version (or credential epoch) on the account, then have every session check that epoch at request time. A background worker can delete old rows later, but authorization must stop immediately when the epoch changes. Repeating the revocation job should be harmless; a retry after a timeout must not create a second audit event that looks like a second password change.
Keep an append-only audit record with actor, reason, account ID, correlation ID, and outcome. Separate operational logs from audit storage so a log-sampling rule cannot erase the evidence an auditor needs. Redact reset links in both systems.
A deployment check should exercise the complete path in a disposable account: issue, redeem, replay, expire, change password, and use an old session. The old session must receive the same authorization result as any other revoked session.
Choose the friction deliberately
There is no universally correct recovery challenge.
| Situation | Lower-friction choice | Higher-assurance choice |
|---|---|---|
| Verified recovery email, low-risk content | Single-use link plus session revocation | Add a recent-device check |
| School identity controls the mailbox | Route through the school identity provider | Require administrator-assisted recovery |
| Payout or sensitive student data is involved | Delay access to sensitive actions | Manual review with two-person approval |
The catch is that stronger checks increase lockouts and support load. A creator publishing lesson plans may tolerate a short delay; a creator handling a live class may not. This design is not suitable when your platform cannot verify an independent recovery channel or cannot invalidate sessions centrally. Stick with administrator-assisted recovery in that case, and make the exception visible in the audit record.
References
Further reading
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Top comments (0)