Short answer: treat password recovery as a short-lived, auditable state machine, and make every existing session visible and individually revocable before issuing a new one. That design gives an account security center a useful session inventory without turning the reset endpoint into a bot oracle.
The incident lesson is a state machine, not an email form
A marketplace forgot-password flow has two assets to protect: the account and the recovery channel. The dangerous failure mode is not a missing button. It is an endpoint that lets an attacker enumerate buyers, spray messages, replay a reset token, or keep a stolen session alive after the owner changes a password.
I plan the flow as four states: requested, challenged, redeemed, and expired. A request creates a random, single-use record with an absolute expiry. A challenge proves control of the mailbox or another enrolled factor. Redemption rotates the password, invalidates recovery records, and marks prior sessions for revocation. Expiry is a normal terminal state, not an error path.
The invariant is simple: one successful recovery must produce one audit event and a bounded set of token transitions. It must not reveal whether an email exists. The response body and timing should be the same for an existing and a nonexistent address, while rate limits and abuse signals remain attached to IP, device, account, and delivery destination.
Keep it boring.
No token lists.
That invariant also changes capacity planning. If a campaign can generate 20 requests per second and each request is retained for 30 minutes, the recovery table needs room for at least 36,000 short records before indexing overhead. In practice, I would reserve headroom for retries, delayed mail callbacks, and an attack that sustains the peak for a full retention window; otherwise the queue looks healthy in a five-minute test and falls behind exactly when operators need the audit trail. Size the queue and audit sink for the same burst, then set an SLO for accepted requests and for audit-event durability separately; a fast email response is not proof that the security record was written.
What should an account security center show for session inventory and remote sign-out?
The center is a read model, not a dump of bearer tokens. For each session, store a random session identifier, a hash of the token, creation and last-seen timestamps, an expiry, coarse user-agent and network metadata, and a reason when it is revoked. Never render the raw token. “Chrome on macOS, last active 12 minutes ago” is actionable; a JWT copied into a dashboard is a second credential leak.
Remote sign-out is a write to the session authority. The API marks the selected session revoked, publishes an invalidation event, and makes subsequent requests fail authorization even if a browser has a cached cookie. A separate “sign out everywhere” operation can rotate the account session epoch, but it must be explicit because it interrupts active checkout and seller workflows.
For a marketplace, I would expose the current session in a distinct row and require step-up authentication before revoking all other rows. Log actor, target session, request correlation ID, and reason. An audit consumer can then answer who signed out a device and when without recovering secrets.
The UI should tolerate stale data. A session can disappear between listing and revocation, so the command is idempotent and returns the same safe result for an already-revoked record. That prevents retries from becoming a source of confusion during an incident.
Implement the recovery and revocation path in Go
The following handler shows the boundary. It accepts a normalized identifier, creates a digest of a one-time token, and emits an audit event only after the state transition succeeds. The repository and queue are generic interfaces so the policy can be tested without a vendor SDK.
package recovery
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
"time"
)
type Store interface {
PutRequest(context.Context, Request) error
}
type Audit interface {
Write(context.Context, Event) error
}
type Request struct {
Digest string
Account string
ExpiresAt time.Time
}
type Event struct {
Kind string
Account string
At time.Time
}
func Start(ctx context.Context, raw string, now time.Time, store Store, audit Audit) error {
account := strings.ToLower(strings.TrimSpace(raw))
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return err
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
digest := sha256.Sum256([]byte(token))
req := Request{
Digest: base64.RawURLEncoding.EncodeToString(digest[:]),
Account: account,
ExpiresAt: now.Add(15 * time.Minute),
}
if err := store.PutRequest(ctx, req); err != nil {
return err
}
return audit.Write(ctx, Event{Kind: "recovery.requested", Account: account, At: now})
}
The mailer receives the token, not the digest, and the redemption transaction compares a digest, checks expiry and a consumed flag, then rotates the password and session epoch in one commit. If the audit sink is unavailable, choose a documented policy: fail the state transition, or enqueue to a durable local outbox. Do not silently claim success.
Every request should carry a correlation ID through the HTTP layer, queue, mail provider, and audit record. Metrics worth alerting on include requests per account, challenge failures, redemption latency, revocations per account, and the ratio of recovery requests to successful redemptions. A spike in the first metric with flat redemptions is an abuse signal, not a reason to loosen the SLO.
How should a marketplace team choose managed or self-hosted controls?
A managed identity service can shorten delivery, while a self-hosted session store can give the platform team sharper control over retention and data locality. The decision is about control boundaries and on-call load, not a claim that one category is universally safer.
| Boundary | Managed identity service | Self-hosted components |
|---|---|---|
| Recovery delivery | Provider handles templates and delivery integrations; verify export and audit semantics | Team owns mail integration, retries, and abuse tuning |
| Session inventory | Often available as a user-facing feature; confirm per-device metadata and revocation guarantees | Schema and read model are explicit, but every migration is yours |
| Incident response | Contract and support escalation matter during an outage | Your SLO and pager absorb storage, queue, and key rotation work |
| Lock-in | Migration depends on token and event portability | More operational work up front, with familiar primitives |
The catch is that a service is not suitable when its session model cannot express your marketplace's seller, buyer, and support-operator boundaries, or when audit export cannot meet your retention policy. Stick with a managed boundary when your team cannot staff 24/7 recovery and key-rotation operations. Build the narrow session authority when revocation semantics, regional storage, or forensic joins are first-order requirements.
Test the failure modes and set the release gate
Start with property tests for the state machine: a redeemed token cannot redeem twice; an expired token never changes a password; a revoked session cannot authorize a request; and two concurrent revocations converge to one final state. Add integration tests that replay messages, delay the audit sink, and submit the same identifier from many IP addresses.
Before launch, require evidence for four gates: enumeration resistance with indistinguishable responses, abuse controls that hold under a measured load profile, an audit trail that survives retries, and an SLO dashboard with an owner. Your mileage may vary on exact thresholds because traffic and mailbox reputation differ, but the gates themselves should not be negotiable.
A security center earns trust when its inventory is honest, its remote sign-out is immediate enough for the stated SLO, and its recovery history can be explained after the fact. That is a narrower promise than “secure accounts,” and it is one a marketplace team can actually operate.
Top comments (0)