DEV Community

SilasFletcher5857
SilasFletcher5857

Posted on

Stolen Session Revocation for Analytics Workspace Access (A User Provisioning Runbook)

Short answer: treat refresh-token rotation, session revocation, user provisioning, and consent checks as one access state machine, then page only when the system cannot enforce a security transition within a measured deadline.

The page fires after a customer reports that a stolen browser session can still open an analytics workspace. On-call sees a valid-looking cookie, a recently disabled user, and successful reads from a cached authorization layer. The immediate action is to revoke the session family, block new access tokens for that family, and verify that the next protected request is denied. Don't start by deleting the user record. That destroys evidence and can turn a contained session event into an account-recovery problem.

The least complex design that works is a central access ledger with monotonic versions. It records the user's provisioned state, the session family's revocation state, and the consent version required for each workspace action. Short-lived access tokens may remain self-contained, but every sensitive analytics request must have a bounded path to fresher state. If there is no such bound, “revoked” is only an administrative label.

What should connect user provisioning, session control, and consent checks for analytics workspace access?

Use one explicit decision model, even if three different services own the underlying data. A request is allowed only when the user is active, the session family has not been revoked, the token was issued against a current account version, and the user has accepted the consent version required by the requested action. Keep authentication and authorization distinct: proving who presented a credential does not prove that credential may export a dataset.

The state can be small:

State Monotonic field Deny when
User lifecycle user_version token version is older, or user is suspended
Session family revoked_at token issue time is earlier than revocation
Workspace membership membership_version cached grant is older than current membership
Consent consent_version accepted version is below the action's required version

Monotonic fields matter during migration because events can arrive out of order. A delayed “user active” event must never overwrite a later suspension, and an old consent acceptance must not satisfy a new policy version. Compare versions in the write path and make stale events no-ops. This is the same reflex used for duplicate queue deliveries: assume replay, make the transition idempotent, and preserve the newer state.

Provisioning should also be reversible without being ambiguous. “Deprovisioned” means new sessions are refused and existing session families are revoked; “suspended” may preserve memberships for investigation; “deleted” belongs to a separate retention workflow. Those transitions need stable identifiers that do not depend on an email address, because email can change or be reused.

Consent is narrower than a general terms-of-service flag. Bind it to the action it governs. Viewing a dashboard, connecting a new data source, and exporting customer-level rows can have different required versions and audit needs. I'm not sure a single global consent timestamp is ever sufficient for a serious analytics product; a data inventory and policy review will settle that for a particular workspace.

Trace the page backward to the missing signal

The visible failure is “revocation did not take effect.” Work backward. The protected request was allowed because the authorization decision used state that was older than the revocation. That stale decision may live in an access token, an edge cache, a process-local cache, or a replicated session store. The useful signal is therefore not the count of revocation API calls. It is the age of the oldest security state that can still produce an allow decision.

Call that measurement enforcement lag. Start its clock when the revocation transition is durably accepted. Stop it when every supported request path either observes the new version or reaches a token expiry that forces reevaluation. Measure separate paths for dashboard reads, exports, API keys, and background jobs; they often have different caching and retry behavior. A single average hides the path that will page you.

This changes the alert from a vague symptom into an action. Page when a high-confidence stolen-session revocation exceeds the documented enforcement objective on a protected path. Create a ticket, rather than a page, when a routine membership removal is slow but still within its safety bound. The distinction matters at 03:00 — urgency should follow exposure, not raw traffic.

Instrument the decision point with bounded, non-secret labels: outcome, reason code, credential type, workspace action, and state age bucket. Do not put tokens, cookies, email addresses, or raw workspace identifiers in metrics. Logs can carry a hashed correlation identifier under the system's retention policy, while traces connect the administrative transition to later authorization checks.

A compact decision function keeps reason codes consistent:

package access

import "time"

type DecisionInput struct {
    UserActive              bool
    TokenUserVersion         uint64
    CurrentUserVersion       uint64
    IssuedAt                 time.Time
    SessionRevokedAt         *time.Time
    AcceptedConsentVersion   uint64
    RequiredConsentVersion   uint64
}

func Decide(in DecisionInput) (bool, string) {
    if !in.UserActive {
        return false, "user_inactive"
    }
    if in.TokenUserVersion < in.CurrentUserVersion {
        return false, "stale_user_version"
    }
    if in.SessionRevokedAt != nil && !in.IssuedAt.After(*in.SessionRevokedAt) {
        return false, "session_revoked"
    }
    if in.AcceptedConsentVersion < in.RequiredConsentVersion {
        return false, "consent_required"
    }
    return true, "allowed"
}
Enter fullscreen mode Exit fullscreen mode

The reason code is operational data, not a client promise. Map denied decisions to the smallest public response that does not reveal account state. OWASP recommends generic authentication error messages so an attacker cannot use response differences to enumerate users. Internally, keep the precise reason for investigation.

One trap deserves a long paragraph. A refresh request races with revocation: both read an active family, revocation commits, and the refresh path tries to publish a child token based on its earlier read. A transaction, compare-and-swap, or version check must make the refresh fail closed when the family version changes. Then retrying the same revocation is harmless, while retrying the refresh cannot resurrect the family. Test both commit orders. Also test a duplicate revocation event, an out-of-order provisioning event, clock skew at the revoked_at boundary, and a background export that began before revocation but has not emitted data. The expected public result is a normal denial such as HTTP 401 for an invalid session or 403 for an authenticated principal lacking permission; choose the mapping once, document it, and don't let each handler improvise.

Stop the leak.

Migrate the control plane without splitting authority

Moving off a managed identity provider is risky when two systems can independently say “allow.” A safer migration gives each decision field one authority at a time. The old system might remain authoritative for identity proofing while the new access ledger owns session-family revocation; later, a controlled cutover moves provisioning authority. Dual-write can collect evidence, but it should not create dual authority.

Run the new decision path in shadow mode before enforcement. For each real request, calculate the candidate decision without changing the response, compare it with the current decision, and classify mismatches by reason. Never log credentials to make that comparison easier. Once mismatches are understood, enable enforcement for a small, reversible workspace cohort, then expand by risk tier. Keep rollback explicit: rollback restores the previous decision reader, but it must not discard revocations or lower monotonic versions written during the trial.

The catch is that self-contained tokens resist immediate revocation unless protected requests consult fresh state or the tokens expire quickly. Central introspection tightens control but adds a runtime dependency and capacity requirement. A hybrid can use short token lifetimes plus a local cache of revocation versions, provided the cache has a hard maximum age and fail behavior chosen per action. Dashboard rendering may tolerate a brief retry; a bulk export should usually fail closed when security state is unavailable.

This architecture is not suitable when the team cannot operate the decision store, key rotation, audit retention, and incident response around it. In that case, keep the managed provider as the authority and narrow the migration to application-level workspace policy. Conversely, a managed control plane may be a poor fit when offline authorization or a tightly bounded, internally operated data path is mandatory. The right choice follows failure ownership, not a feature-count spreadsheet.

Cost still belongs in the review, but use the workload shape: active session families, protected requests per second, revocation bursts, audit volume, and cross-region reads. A per-user quote and a per-request architecture move differently as dormant workspaces accumulate. Model both, including on-call and compliance work. Your mileage may vary because cache bounds and export traffic dominate many installations more than login volume does.

Prove revocation before trusting the alert

Test the security invariant at the boundary, not just the administrative endpoint. Create a session family, rotate its refresh token once, start an authorized workspace request, revoke the family, and then attempt both the old and newest refresh tokens. Neither may create a usable descendant after the revocation commit. Repeat with duplicate messages and reversed delivery order. For consent, increment the required version and confirm that a previously accepted version denies the governed action until a new acceptance is recorded.

The runbook should answer four questions without a code search: which session family is affected, when the transition became durable, which request paths have observed it, and how to contain access if the normal propagation bound is exceeded. Containment might disable exports for one workspace while leaving read-only dashboards available. That is a product decision made before the page, not an improvisation by a tired responder.

Alert thresholds need two dimensions: lag and evidence of attempted use. A revocation that is 20 seconds old with repeated denied refresh attempts deserves different handling from a quiet, routine logout at the same age. Those numbers are examples, not universal objectives. Derive the real threshold from token lifetime, cache maximum age, replication behavior, and the business impact of each action; then validate it with controlled revocation drills.

Getting the threshold wrong has a cost. Too loose, and the page arrives after the unsafe window readers thought they had closed. Too tight, and ordinary propagation jitter wakes someone who can do nothing except watch the system converge. I've been paged by missed jobs and duplicate deliveries; the useful lesson carries over cleanly here: alert on a violated user-visible invariant with a named response, not on motion inside a queue.

References

Further reading

Top comments (0)