DEV Community

IronspireDraven77
IronspireDraven77

Posted on

Node.js Logout Button: Signing Out All Devices After Suspicious Activity Explained

Use one account-wide revocation boundary for both the "sign out all devices" button and a completed password reset, and make every protected request check it before marketplace data is returned. A password change that leaves a stolen session usable is an unfinished recovery operation.

TL;DR: For a Node.js application using Express middleware, keep an authoritative session generation on the account, bind each session to the generation at issuance, and reject a mismatch. Increment that generation when the user confirms suspicious activity or successfully completes password recovery. Protect the recovery path against enumeration and automated abuse; an anonymous forgot-password request must never log the account out.

This is a proposed implementation contract, with a Go reference implementation of the server-side checks. The operational question is specific: after a seller recovers their account, can an older browser session still change a payout destination?

What page fired, and what would prove containment?

Ask what page fired before reading the dashboard. A rise in password-reset requests can mean an attacker is sending unwanted messages, a delivery problem is causing repeated attempts, or legitimate users are struggling; the graph alone doesn't establish which account needs containment, and treating every reset request as evidence of compromise would give a bot an account-disruption primitive.

An incident review needs a narrower claim. For a hypothetical marketplace account, seller-482, the desired sequence is: a suspicious-activity report prompts confirmation, the server commits account-wide revocation, and a request carrying an older session is denied. Record enough to connect those events without recording the session credential itself. A green button-click metric proves very little.

Check the boundary.

The OWASP Authentication Cheat Sheet recommends generic authentication responses, reauthentication after risk events such as account recovery, and session invalidation and rotation around reauthentication. Those recommendations establish the security objective. The generation counter described here is an implementation choice for making that objective testable across devices, rather than a claim that a counter alone establishes the identity of the person asking for recovery.

For the forgot-password entry point, return a generic acknowledgment regardless of whether the account exists. Keep the externally visible response path comparable, including timing, and apply abuse controls to both known and unknown identifiers. Otherwise the recovery form doubles as an account-discovery tool. That matters in a marketplace, where identifying active sellers can make later targeting easier.

How should a sign out all devices button respond after suspicious activity?

Model each account with a monotonically increasing generation, starting at a chosen value such as 1. A session stores the generation current when it was issued; two browsers might therefore both hold generation 7. Account-wide logout advances the account to 8. Neither browser should regain access by refreshing a token that still belongs to generation 7.

These are example values, not production measurements.

The Express middleware contract is straightforward: validate the session credential, resolve the account and session generation from trusted server-side state, read the current account generation, compare them, and call the next handler only on a match. A caller-supplied account identifier or generation isn't proof of anything. Attach the validated account identity to request-local state so a later logout handler doesn't select its target from an arbitrary request body.

Keep the distinction between authentication and authorization. A matching generation says the session hasn't crossed this revocation boundary; it doesn't establish that a seller may change another seller's payout details. The existing authorization check still belongs at that operation.

Refreshing deserves explicit attention. Check the same generation before issuing a replacement credential, or an attacker holding an old refresh credential can rebuild access after the button appeared to work. Login and reset completion also need consistent issuance: capture the generation as part of the authorized issuance operation, rather than reading a newer generation later and attaching it to an authentication decision made before containment.

Choose the consistency policy deliberately. A cached generation creates a window during which an older session may pass. For the proposed payout and recovery paths, read authoritative state and deny access if that check cannot complete. The trade-off is availability: this approach requires a reachable authoritative store for each protected request. If other marketplace reads use a cache, document their permitted revocation delay separately; don't let that weaker guarantee silently reach payout changes. Consider the proposed seller test with 2 browser sessions and a pending payout request: a successful generation update closes the boundary only for operations that actually consult that state, so an old application process, a refresh handler with a separate validation path, or a delayed mutation can each invalidate the containment claim even while the logout response looks correct. Review those paths together before deciding that the store dependency is worth accepting.

Cache freshness matters.

There is another edge: revocation cannot undo a request that already passed middleware. For sensitive mutations, compare the expected generation again within the transaction that authorizes and commits the change, with appropriate database concurrency control. Otherwise a request can pass the check, wait, and change a payout destination after recovery has completed.

Implement a small, explicit enforcement contract

The following Go package models the shared middleware and revocation contract. It assumes an earlier authentication step has already validated expiry and other credential requirements and placed trusted session data in the request context. Its interfaces are application-defined, not SDK calls or a complete password-reset service.

package recovery

import (
    "context"
    "errors"
    "net/http"
)

type Session struct {
    AccountID  string
    Generation int64
}

type sessionKey struct{}

func WithSession(ctx context.Context, s Session) context.Context {
    return context.WithValue(ctx, sessionKey{}, s)
}

func authenticatedSession(ctx context.Context) (Session, bool) {
    s, ok := ctx.Value(sessionKey{}).(Session)
    return s, ok && s.AccountID != ""
}

var ErrStale = errors.New("session generation is stale")

type Store interface {
    CurrentGeneration(context.Context, string) (int64, error)
    // Atomically advance only when the generation equals expected.
    // A mismatch must return ErrStale without changing state.
    AdvanceGeneration(context.Context, string, int64) error
}

func RequireCurrent(store Store, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        s, ok := authenticatedSession(r.Context())
        if !ok {
            http.Error(w, "authentication required", http.StatusUnauthorized)
            return
        }
        current, err := store.CurrentGeneration(r.Context(), s.AccountID)
        if err != nil {
            http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
            return
        }
        if current != s.Generation {
            http.Error(w, "authentication required", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// Mount behind session authentication, RequireCurrent, recent
// reauthentication, and CSRF protection for cookie-based sessions.
func SignOutAll(store Store) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            w.Header().Set("Allow", http.MethodPost)
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        s, ok := authenticatedSession(r.Context())
        if !ok {
            http.Error(w, "authentication required", http.StatusUnauthorized)
            return
        }
        err := store.AdvanceGeneration(r.Context(), s.AccountID, s.Generation)
        if errors.Is(err, ErrStale) {
            http.Error(w, "authentication required", http.StatusUnauthorized)
            return
        }
        if err != nil {
            http.Error(w, "revocation unavailable", http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusNoContent)
    })
}
Enter fullscreen mode Exit fullscreen mode

The atomic advance matters even though middleware already checked the generation. Two requests can pass that check together; conditional advancement ensures that a stale session cannot keep advancing the account after another recovery action has already revoked it. The store implementation must enforce the condition in the database operation, not as a separate application read followed by an unconditional write.

This design signs out the initiating session too. Say so beside the button. If the product later chooses to preserve that browser, require fresh authentication and issue a new session through the normal issuance boundary; copying the old credential into the new generation would defeat the point.

A successful response means the generation update committed. Emit audit evidence tied to that commit through a transactional audit record or equivalent durable mechanism. Record the account, actor category, action, outcome, and correlation identifier. Exclude password-reset secrets, session tokens, and passwords.

Make recovery expensive to abuse without creating a lockout tool

Separate reset initiation from reset completion. Initiation acknowledges the request and, when appropriate, arranges delivery of a recovery message; it does not increment the generation, change the password, or invalidate the seller's sessions. Completion proves possession of the recovery credential, applies the new password, consumes that credential, and advances the generation through a coordinated commit boundary.

Require reset credentials to expire and be single-use. Successful completion must not leave a second concurrent redemption able to succeed.

The generation counter doesn't provide either property by itself.

For bot resistance, combine controls around the submitted identifier with broader request-source limits and message-delivery limits. An IP-only limit can punish multiple sellers sharing a network while leaving distributed attempts relatively unconstrained. An account-only hard lockout creates another disruption tool. Tune limits against observed legitimate recovery behavior, keep acknowledgments generic, and make rejected attempts visible to operators without exposing account existence to the requester. No universal request threshold follows from this architecture.

The support workflow belongs in the same review. If an agent can bypass recovery checks or restore an old session, the public form's controls aren't the whole security boundary. Require a documented authority for recovery actions and record the reason for each privileged intervention. Keep audit evidence useful enough to reconstruct what was authorized, without turning the audit stream into a credential collection.

Verify containment, then plan rollback around it

Start with two authenticated sessions for the same test seller and one for an unrelated buyer. Confirm that both seller sessions work before revocation. Invoke account-wide logout through the protected handler, then replay each older seller session against a protected read, a payout mutation, and the refresh path. All must fail authentication after the relevant commit boundary; the buyer's session should continue working.

Test the races, too.

Pause a sensitive request after its middleware check, commit recovery, and resume the mutation. It must fail its transactional generation check. Race 2 reset redemptions and 2 revocation requests. Verify that the loser cannot mint credentials or advance state using stale authority.

An audit acceptance test should also cover unknown identifiers, repeated reset initiation, expired recovery credentials, rejected requests, and unavailable authoritative state. Check response behavior and delivery effects, rather than only the text in the browser. Repeated anonymous requests must not invalidate a seller's existing sessions.

Deploy by introducing the account field and session metadata before requiring their comparison. Define what happens to legacy sessions that lack a generation; for a security-sensitive cutover, require fresh authentication instead of treating missing metadata as current. Observe rejection reasons, store-check failures, and recovery completion outcomes. These signals help explain an incident, but a synthetic attempt with an old credential supplies stronger evidence of enforcement than a dashboard showing successful logout responses.

Rollback has a security invariant: never decrement the generation or disable enforcement in a way that revives revoked credentials. A compatible previous application version must honor the same boundary. If it cannot, contain affected access while restoring a compatible version, and keep the committed revocation state.

The audit question is concrete: can an old credential still authorize a marketplace action after recovery commits? Keep that question attached to the button, the password-reset transaction, the refresh path, and the rollback procedure.

References

Top comments (0)