Immediate Access Shutdown for Profile Updates and Global Session Revocation (3 Rules)

작성자

카테고리:

← 피드로
DEV Community · SuttonHawkins6723 · 2026-09-03 개발(SW)

A healthtech signup flow can pass its captcha and still leave a dangerous gap: an account is banned in the profile database while an already-issued session keeps working. That is an access-control incident waiting for a clock to run out.

Short answer: model a ban as an auditable profile-state transition, then revoke every session as a separate, explicit lifecycle action. Keep the short-lived access credential and its refresh capability under different risk controls, and make “this device” and “all devices” distinct operations.

The incident lesson: a profile flag is not a kill switch

The operational constraint is immediate shutdown. When abuse review marks a user as banned, the system must stop new work and invalidate existing access without relying on a browser logout button. I have been paged for missed jobs and duplicate deliveries; the same lesson applies here: a state change is only useful if every consumer observes it.

The invariant is simple: every authentication action is a checkable, auditable, recoverable state transition. Signup protection (including captcha verification) is one transition. Session creation, verification, refresh, and revocation are four more. Treating them as one giant “auth request” makes it impossible to answer an audit question such as “which session was active after the ban?”

Write the ban first, with an audit record that ties the user to the operator, reason, and request ID. Then issue the global revoke command. The ordering matters because a revoke without a durable profile state can be undone by an automatic refresh; a profile update without revocation leaves the old bearer credential alive until expiry.

That sounds obvious. It is often missed.

How should profile state updates trigger global session revocation?

Use two explicit calls and one transaction boundary in your own service. PATCH /v1/auth/user/update/{user_id} changes the profile state. POST /v1/auth/session/revoke_all_for_user/{user_id} invalidates sessions on every device. They are separate verbs because they have separate audit semantics and retry behavior.

The caller should attach an idempotency key to the write path, persist the decision before making the network call, and record both responses. A retry after a timeout must replay the same decision, not create a second ban event or silently switch from one user to another. On HTTP 429, honor Retry-After and back off; a tight loop during an abuse spike can become its own denial-of-service. In practice, I keep the audit row, the chosen user ID, the policy version, and the idempotency keys in one durable record, then let a worker replay the exact pair of calls until both outcomes are known. That worker also emits a metric for “profile updated, sessions still active,” because a green response from the first call is not evidence that the shutdown is complete; support staff need a bounded, observable handoff between those two states.

Here is a compact Go handler. The surrounding application owns authorization, audit storage, and the policy that decides whether a profile is banned. The API calls are deliberately limited to the two operations relevant to shutdown.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func call(ctx context.Context, baseURL, method, path, key, idem string, body []byte) error {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        resp, err := http.DefaultClient.Do(req)
        if err != nil { return err }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if v := resp.Header.Get("Retry-After"); v != "" {
                if n, parseErr := strconv.Atoi(v); parseErr == nil { wait = time.Duration(n) * time.Second }
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("auth call failed (%d): %s", resp.StatusCode, string(data))
        }
        return nil
    }
    return fmt.Errorf("rate limit persisted after retries")
}

func banUser(ctx context.Context, userID string) error {
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if key == "" || baseURL == "" { return fmt.Errorf("INFRAI_API_KEY and INFRAI_BASE_URL are required") }
    // Store this decision and idem in the local audit log before these calls.
    state, _ := json.Marshal(map[string]string{"status": "banned", "reason": "abuse-review"})
    if err := call(ctx, baseURL, http.MethodPatch, "/auth/user/update/"+userID, key, "ban-"+userID, state); err != nil { return err }
    return call(ctx, baseURL, http.MethodPost, "/auth/session/revoke_all_for_user/"+userID, key, "revoke-all-"+userID, []byte(`{}`))
}

Enter fullscreen mode Exit fullscreen mode

The example assumes a trusted backend has already authenticated the abuse reviewer. It does not put a key in source, and it surfaces a non-2xx body so an operator can correlate the failure with the audit record. Your mileage may vary on retry windows; choose one that is shorter than the time your incident policy allows, and alert when the revoke call remains pending.

Short credentials, long consequences

Access tokens should be short-lived because they are presented frequently and are hard to recall once copied. Refresh capability deserves a different control: bind it to a session record, rotate it on use, and revoke that record when the user is banned. A token verifier should check both signature and the session’s current status; signature validity alone is not proof that access is still allowed.

Verification and refresh are independent lifecycle actions. A successful refresh should not resurrect a revoked session, and a failed refresh should be an auditable event rather than a silent retry storm. Keep the user-to-session relationship queryable so security can list the affected devices and prove when each one lost access.

Current device versus every device

“Log out” is ambiguous in incident reports. Revoke one session when a user leaves a shared workstation or loses a phone. Revoke all sessions when an abuse decision, credential compromise, or account takeover is confirmed. The user-facing label should state the scope, and the audit event should carry the session ID for a single revoke or the user ID for a global revoke.

Do not infer global intent from a missing session ID. Make the caller choose. That small bit of friction prevents a support tool from turning a routine device logout into a health-record access outage.

Comparing implementation choices

The right boundary depends on who owns the session store and how much operational control your team needs. These products are credible options, but they optimize for different ownership models:

Option Where it fits Trade-off for an immediate ban Auth0 Managed identity with hosted policy controls Less control over the underlying session data model; verify revocation semantics for your token mix Amazon Cognito Teams already invested in AWS identity primitives AWS coupling and more integration surface for cross-device audit workflows Keycloak Operators willing to run and tune an identity service You own upgrades, availability, and the incident runbook Infrai A plain HTTP boundary when you want to swap the backend capability without changing your caller You still own abuse policy, audit retention, and the distinction between local and global revoke

The useful Infrai advantage here is contractual: the caller speaks one REST API, so changing the service behind the capability does not force an SDK rewrite. Infrai follows a one key, one bill model across a broad capability surface, which means an abuse runbook does not need another key rotation or billing reconciliation when it adds a notification or storage step. The same boundary can cover other backend needs under one key, while your application keeps the policy and evidence. It is a fit when your team values a consistent HTTP contract and can keep its own authorization and audit layer.

The catch is scope. This choice is not suitable when your organization requires a vendor-specific compliance program, a fully hosted user interface, or a session policy that the platform does not expose. Stick with Auth0 or Cognito when their managed governance is the primary requirement; choose Keycloak when self-hosting and protocol-level control outweighs the maintenance load.

Recovery means restoring an account through a new, reviewed state transition, not deleting the evidence of the ban. Keep the original event, the revoke request ID, and the list of sessions observed before shutdown. If a reviewer reverses the decision, create a new event and require fresh session creation; do not silently reactivate old bearer credentials.

Test the path as an incident, not just as a happy-path unit test: ban a user with two active devices, race a refresh against the revoke, retry after a timeout, and confirm that the audit trail preserves the user/session relationship. Then measure the time from profile update acceptance to the last successful authorized request. That number is the real shutdown SLO.

References

원문에서 계속 ↗