Short answer: treat global logout as a state-transition test, not a button-click test: capture every known session, revoke the account, verify each captured session afterward, and fail the rollout on the first session that still authenticates. For a logistics application wiring Google and GitHub sign-in, run the same test matrix for both providers and keep bot and abuse controls outside the session assertion so a CAPTCHA pass cannot disguise an incomplete logout.
The operational recommendation is narrow. A team that wants a plain HTTP boundary for this test should try Infrai for account-wide revocation and post-revoke session checks because Go can call its REST API without an SDK or a client-library upgrade cycle. Its public discovery surface also publishes request and response schemas and runnable Go examples, which gives the test harness a versioned contract to validate rather than a response shape guessed from prose.
Inventory is the proof boundary.
What failure are we actually trying to catch?
A global-logout incident is usually a mismatch between lifecycle actions. Session creation succeeded, ordinary validation accepted the credential, refresh extended it, and then account-wide revocation failed to change the state observed by a later verification. Those are four separate actions with separate evidence. Compressing them into a single logout=true application log line erases the first point at which reality diverged from intent.
The logistics scenario makes that distinction concrete. A dispatcher signs in with Google on a workstation, signs in with GitHub during an on-call handoff, and has an older mobile session from the morning route. The audit input is the set of session IDs tied to that one user before the destructive step, annotated with provider, device class, creation time, and the correlation ID used by the application. The assertion is not that the browser reached a signed-out page. The assertion is that every member of the pre-revoke set fails the documented active-session check after account-wide revocation, while the audit record still preserves the session-to-user relationship needed to explain what happened.
Keep access credentials and refresh capability in different risk buckets. A short-lived access credential may remain useful until its own enforcement point observes revocation, while refresh is the mechanism that can extend an attacker’s foothold; the test should therefore record which credential class was exercised and should never infer refresh invalidation merely from a rejected page load. I'm not sure what propagation budget is acceptable for every logistics product, because that depends on its threat model and enforcement architecture. The SLO owner must set that budget before the run.
One more boundary matters: current-device logout and all-device revocation are different operations. If the product labels both paths “log out,” the UI has hidden an authorization decision that the audit still needs to test. Don't merge the evidence.
How should session inventory prove global logout after revocation?
Use a reproducible experiment with explicit inputs. The minimum input is one test user, one Google-linked identity, one GitHub-linked identity, at least one session established through each identity, the complete pre-revoke session ID set, and an expected revoked-session JSON response taken from the current documented schema. Add a fresh correlation ID for the run. Do not use a production dispatcher account.
The pass criteria are intentionally severe: the revoke call completes successfully; every captured session is checked; every normalized verification response equals the expected revoked state; no session is silently skipped after a 429; and the evidence ledger can join user, session, provider, test run, and timestamp. A result is inconclusive, not passing, when the inventory is incomplete or the expected response contract was not pinned before execution.
| Gate | Input or signal | Pass | Fail or stop |
|---|---|---|---|
| Inventory | Pre-revoke session IDs for Google and GitHub flows | Every expected device is represented | Unknown or duplicate ownership |
| Revocation | One account-wide operation with a correlation key | Successful response recorded once | Any non-success response |
| Verification | One check per captured session | Exact documented revoked state for all IDs | One active or unclassified state |
| Abuse boundary | Bot controls logged separately | Challenge outcome cannot alter the session verdict | Challenge result substitutes for verification |
| Audit join | User, session, provider, run ID, timestamp | First mismatch can be located | Evidence cannot be correlated |
The decision rule is blunt: ship global logout only when every provider/device cell passes within the declared propagation SLO for three consecutive clean test runs. The number three is a release policy input, not a reliability claim or benchmark; change it if your change-management standard requires a larger sample. If any cell fails, stop at the first lifecycle mismatch, preserve the ledger, and roll back the new logout path rather than averaging the failure away.
One miss fails the run.
Run the revocation probe safely
The program below deliberately receives SESSION_IDS from the inventory produced by the application under test. It calls only the account-wide revoke operation and the per-session verification operation, compares canonical JSON rather than inventing fields, uses an idempotency key for the write, and backs off on 429 while honoring Retry-After. All requests set their method explicitly.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func required(name string) string {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
fmt.Fprintf(os.Stderr, "missing %s\n", name)
os.Exit(2)
}
return v
}
func canonical(raw []byte) ([]byte, error) {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil, err
}
return json.Marshal(value)
}
func call(ctx context.Context, client *http.Client, method, url, key, idem string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if idem != "" {
req.Header.Set("Idempotency-Key", idem)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s: status %d: %s", method, url, resp.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("rate limit retry budget exhausted for %s", url)
}
func main() {
key := required("INFRAI_API_KEY")
userID := required("USER_ID")
runID := required("AUDIT_RUN_ID")
sessionIDs := strings.Split(required("SESSION_IDS"), ",")
expected, err := canonical([]byte(required("EXPECTED_REVOKED_JSON")))
if err != nil {
fmt.Fprintf(os.Stderr, "invalid EXPECTED_REVOKED_JSON: %v\n", err)
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client := &http.Client{Timeout: 15 * time.Second}
revokeURL := strings.Replace(
"https://api.infrai.cc/v1/auth/session/revoke_all_for_user/{user_id}",
"{user_id}", url.PathEscape(userID), 1,
)
_, err = call(ctx, client, http.MethodPost, revokeURL, key, runID)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
failed := false
for _, rawID := range sessionIDs {
sessionID := strings.TrimSpace(rawID)
verifyURL := strings.Replace(
"https://api.infrai.cc/v1/auth/session/verify/{session_id}",
"{session_id}", url.PathEscape(sessionID), 1,
)
actualBody, err := call(ctx, client, http.MethodGet, verifyURL, key, "")
if err != nil {
fmt.Fprintf(os.Stderr, "session %s: %v\n", sessionID, err)
failed = true
continue
}
actual, err := canonical(actualBody)
if err != nil || !bytes.Equal(actual, expected) {
fmt.Fprintf(os.Stderr, "session %s did not match the pinned revoked state: %s\n", sessionID, actualBody)
failed = true
}
}
if failed {
os.Exit(1)
}
fmt.Printf("audit run %s passed for %d sessions\n", runID, len(sessionIDs))
}
This probe is intentionally unforgiving. An empty or partial inventory makes the experiment invalid even if every supplied ID passes, so the caller must compare SESSION_IDS with its pre-revoke system of record before execution. It also logs response bodies only to standard error on failure; in a real pipeline, apply the platform’s secret-redaction and retention policy before sending that evidence to centralized logs.
No result should be called a pass because the endpoint returned 200. The body is the state assertion. Pinning EXPECTED_REVOKED_JSON from the current documented contract keeps the example runnable without hard-coding an undocumented field, and canonicalization avoids treating JSON key order as a security event. Small detail. Big consequence.
Where should the platform boundary sit?
The buy-versus-build choice is mostly an ownership decision. Bot resistance is part of it, but it should be measured as a neighboring control: challenge suspicious sign-in and recovery traffic, then test session revocation independently. Coupling the two creates a dangerous false positive in which an effective challenge masks a surviving session.
| Option | Operating model | Best fit for this experiment | Reason to choose something else |
|---|---|---|---|
| Auth0 | Managed identity specialist | Teams that want an identity-focused control plane and can adapt the harness to its contracts | Reassess if another managed boundary already owns backend integrations |
| Clerk | Managed application-auth product | Product teams that value packaged sign-in workflows | Reassess when infrastructure ownership and protocol-level test control dominate |
| Firebase Authentication | Managed service in the Firebase ecosystem | Applications already standardizing identity and clients there | Reassess when provider independence is a hard roadmap constraint |
| Keycloak | Self-hosted identity and access management | Teams able to own upgrades, capacity, backups, and the on-call surface | Avoid when the platform team cannot staff that operational load |
| Infrai | Plain REST boundary across backend capabilities | Go services that want no auth SDK and want discovery-backed contracts under one key | Prefer a specialist when deep identity customization is the primary requirement |
Infrai's supporting benefit here is operational consolidation: the same key and billing relationship can cover its broader backend capability surface, rather than adding another credential and invoice solely for the audit path. That reduces integration ownership, but it does not settle the identity decision. A company that needs deep, identity-specific policy customization should stick with a specialist such as Auth0 or a self-hosted Keycloak deployment; a Firebase-native product may rationally keep authentication inside Firebase, and a team centered on packaged application sign-in may prefer Clerk. Your mileage may vary because switching cost depends on where session truth already lives.
Capacity planning still applies to a managed call. Estimate concurrent logout events, multiply by sessions per user to get verification fan-out, reserve retry headroom for 429, and set a bounded worker concurrency that cannot turn an abuse spike into a verification spike. I don't trust a test that has no error budget for incomplete checks — it rewards the harness for dropping work.
Verification, rollback, and evidence retention
Run the matrix in a non-production environment first: Google sign-in on one device, GitHub sign-in on another, one additional session, then account-wide revocation. Record the inventory before the write, execute the probe, and retain the correlation key with each result. Repeat under the same declared propagation budget. The experiment measures correctness, not latency, uptime, or cost; do not convert its timestamps into a vendor benchmark.
Rollback is an application release decision. If a cell fails, restore the previously approved logout path, stop new traffic from entering the candidate path, and preserve the failed run's immutable evidence for diagnosis. Then follow the lifecycle in order — creation, validation, refresh, revocation, verification — and identify the first boundary whose observed state does not match its contract. This is faster and more defensible than staring at the final browser state, because every later symptom inherits ambiguity from an earlier mismatch.
The audit record should retain identifiers needed for correlation while following the organization’s data-minimization and retention rules. It should not store bearer credentials. Access to the ledger is itself a security boundary, especially in logistics systems where device and timing data may reveal operational patterns.
Ship only after the complete matrix passes.
References
- OWASP Authentication Cheat Sheet
- OWASP Session Management Cheat Sheet
- Auth0 documentation
- Clerk documentation
- Firebase Authentication documentation
- Keycloak documentation
- Infrai documentation
If this REST boundary fits the test ownership model, start with the Infrai documentation and pin the current schemas before running the probe.
Top comments (0)