Short answer: treat global logout as a reconciliation job. Snapshot every session, revoke from that inventory, then prove each identifier is rejected after revocation; a success banner is not evidence.
An edtech forgot-password flow makes this painfully concrete. A learner may have a browser, a phone, a shared family tablet, and a proctoring window open at once. Resetting the password without closing those sessions leaves the old bearer credentials useful. The security decision is session security versus friction: revoke everything for a high-risk reset, and make an ordinary password change less disruptive only when policy allows it.
I have been paged for missed jobs and duplicate deliveries, so I use the same operating rule here: an action is incomplete until its recorded result can be reconciled with an independent check.
The incident lesson: logout is a state transition, not a button
The failure mode is usually an inventory problem. The logout handler knows about the current cookie, while refresh tokens, mobile sessions, and older web sessions live in different stores. It returns 200, deletes one record, and the UI says “signed out.” The next request from a second device still succeeds. In a review, I draw the timeline on a whiteboard: the reset request arrives, the inventory cutoff is chosen, one database replica observes the revoke, a cache serves an older decision, and a phone presents a refresh token that was never in the browser table. Each hop needs an owner and a timestamp. The report must distinguish a token that was absent at cutoff from a token that was present and rejected. That distinction changes both incident response and what a reviewer can reproduce. We also keep the probe harness outside the normal learner path, because an audit check must not alter course progress, consume a one-time recovery code, or trigger a notification storm. A bounded queue gives operators a place to pause, resume, and inspect work without pretending that eventual completion happened immediately.
For a forgot-password request, record a risk decision before sending the reset message. A verified recovery can require global logout; an unverified or suspicious request should not grant a new session at all. Keep the reset token separate from session identifiers, hash both at rest, and give each record an explicit expiry. Never put raw tokens in an audit event.
The invariant is simple: for a user and a revocation event, every session that was active at the inventory cutoff must be either revoked or explicitly accounted for. “Unknown” is an incident state, not a pass.\n\nStop.\n\nExactly.
What should session inventory and post-revoke verification prove?
Start with a point-in-time inventory. Capture a random session ID, user ID, issuance time, last-seen time, client class, token family, storage shard, and current status. Capture the inventory cutoff and the policy version too. Those fields let an auditor answer which credentials existed, which were targeted, and why an exception was allowed.
Then perform revocation as an idempotent operation. Repeating the command must not create a second event or resurrect a session. Use a per-user revocation generation (or equivalent server-side version) that every request checks. A token issued before the generation is invalid even if a cache still contains it.
Post-revoke verification is an active probe, not a database query. For every inventory row, present a bounded test request using a non-secret test credential or a safely isolated replay harness. Expect an authentication failure such as HTTP 401, and record the observed decision, timestamp, and verifier version. Do not log the credential. If a row cannot be probed, leave it pending and page an operator; do not convert missing evidence into success.
A small Go service can make the decision path explicit:
type Session struct {
ID string
UserID string
IssuedAt time.Time
Status string
}
type RevocationStore interface {
Inventory(ctx context.Context, userID string, cutoff time.Time) ([]Session, error)
Revoke(ctx context.Context, sessionID, eventID string) error
Generation(ctx context.Context, userID string) (int64, error)
BumpGeneration(ctx context.Context, userID, eventID string) error
}
type Probe func(context.Context, Session) (int, error)
func GlobalLogout(ctx context.Context, store RevocationStore, probe Probe, userID, eventID string, now time.Time) error {
rows, err := store.Inventory(ctx, userID, now)
if err != nil {
return fmt.Errorf("inventory: %w", err)
}
if err := store.BumpGeneration(ctx, userID, eventID); err != nil {
return fmt.Errorf("generation: %w", err)
}
for _, row := range rows {
if err := store.Revoke(ctx, row.ID, eventID); err != nil {
return fmt.Errorf("revoke %s: %w", row.ID, err)
}
status, err := probe(ctx, row)
if err != nil {
return fmt.Errorf("probe %s: %w", row.ID, err)
}
if status != http.StatusUnauthorized {
return fmt.Errorf("session %s remained usable: got %d", row.ID, status)
}
}
return nil
}
The write order matters. Persist the event and cutoff, bump the generation, revoke rows, and only then mark verification complete. A worker can retry safely because eventID is the idempotency key. If the process stops after row 17, the audit record says exactly where it stopped and the next run resumes from the same inventory.
Comparing controls by friction and evidence
There is no universal “logout everywhere” setting. Choose the control that matches the threat and the evidence you must produce:
| Control | Security effect | User friction | Audit evidence |
|---|---|---|---|
| Current-session logout | Closes one browser context | Low | One session ID and probe |
| Password reset with generation bump | Invalidates older token families | Medium | Cutoff, generation, row outcomes |
| Global logout plus active probes | Tests every inventoried session | High during class or exam | Per-session 401 results or pending state |
| Short session lifetime alone | Limits exposure over time | Periodic sign-ins | Expiry policy, not proof of immediate revoke |
A short lifetime is useful containment, but it cannot prove that a stolen refresh token is unusable now. Conversely, probing thousands of devices synchronously can create a denial-of-service shape and delay recovery. Queue probes with a deadline, cap concurrency, and expose the pending count to support staff.
The catch is that inventory completeness depends on architecture. If a legacy mobile client never registers its session, no reconciliation algorithm can verify it. In that case, require a token-family generation check at the gateway and document the coverage boundary. Global logout is not suitable when the product cannot enforce a shared verifier; stick with a smaller guarantee and state it plainly.
How do you run this as an SRE check?
Make the workflow observable. Emit one structured audit event for the request, inventory cutoff, generation bump, each revoke attempt, each probe result, and the final disposition. Use a correlation ID that support can search without exposing account secrets. Useful metrics are inventory rows per user, revoke latency, probe failure count, pending age, and the percentage of events closed with independent evidence. Alert on a non-zero “usable after revoke” result and on pending probes older than the policy window.
Test the unpleasant paths before production: duplicate clicks, retries after a worker crash, cache lag, a session created exactly at the cutoff, and a reset token redeemed concurrently with logout. Property tests should assert that replaying the same eventID leaves one logical event and that every request checks the current generation.
For audit review, retain an immutable event stream and a separately generated report. The report should include the policy version and a hash of the inventory contents, while access to raw session metadata remains restricted. OWASP recommends reauthentication and session invalidation controls around credential recovery; map that guidance to your own risk tiers rather than claiming that one flow fits every learner.
Your mileage may vary: browser cookies, native refresh tokens, and third-party identity sessions have different revocation semantics. I’m not sure a single dashboard can represent all of them honestly, so I prefer explicit “covered,” “not covered,” and “pending” states over a green aggregate.
A practical decision rule for forgot-password recovery
If the reset request is high risk, verify the recovery factor, inventory all server-verifiable sessions, bump the user generation, revoke every row, and require all probes to return 401 before declaring global logout complete. Notify the learner that other devices may need to sign in again.
For lower-risk changes, preserve the current session only when the policy and threat model permit it, and still invalidate older refresh-token families. Record the exception and its reason.
This is the operational boundary: an audit can defend a clear guarantee with evidence, or a clear limitation with a compensating control. It cannot defend a success message that was never checked.
Top comments (0)