Revoking every session for a user after a suspected breach

The revoke-all endpoint, the except_session_id escape hatch, and the window between revocation and expiry that offline JWT verification leaves open.

When you need a user logged out everywhere, Infrai gives you POST /v1/auth/session/revoke_all_for_user/{user_id}. One call, every device. GET /v1/auth/session/list_for_user/{user_id} shows you what you’re about to kill, and POST /v1/auth/session/revoke/{session_id} handles the single-device case.

The subtlety isn’t the call. It’s that revoking a session and invalidating an already-issued access token are different events, separated by however long that token has left to live.

See the sessions first

curl -sS "https://api.infrai.cc/v1/auth/session/list_for_user/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "session_id": "au_ses_7Uu2kQxWvR4mBn8dTcYs",
        "user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
        "started_at": "2026-09-21T02:24:30Z",
        "expires_at": "2026-09-28T02:24:30Z",
        "ip": "203.0.113.24",
        "ua": "Mozilla/5.0 (Macintosh)",
        "mfa_factor": null
      },
      {
        "session_id": "au_ses_Kq81Lm4vBtY6ncXzWdRa",
        "user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
        "started_at": "2026-09-19T18:02:11Z",
        "expires_at": "2026-09-26T18:02:11Z",
        "ip": "198.51.100.7",
        "ua": "okhttp/4.12.0",
        "mfa_factor": null
      }
    ]
  }
}

Two sessions, two very different clients: a Mac browser and an Android app. The ip and ua are what make a security screen useful — the user recognises “Mac in Berlin” and doesn’t recognise the other one.

Revoke one, or revoke all

Single device, for the “this wasn’t me” button next to a session row:

curl -sS -X POST "https://api.infrai.cc/v1/auth/session/revoke/au_ses_Kq81Lm4vBtY6ncXzWdRa" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"session_id": "au_ses_Kq81Lm4vBtY6ncXzWdRa"}'

Everything, for a breach:

curl -sS -X POST \
  "https://api.infrai.cc/v1/auth/session/revoke_all_for_user/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB"}'
{
  "ok": true,
  "data": { "ok": true, "count": 2 }
}

count is how many were actually closed. Log it — it’s the number you’ll want in the incident write-up, and a count of zero tells you the sessions had already expired.

Add except_session_id when the user is doing this to themselves from inside the app. Changing a password and then immediately logging yourself out of the tab you’re standing in reads as a bug, even though every session really did close.

The window, stated plainly

If your backend verifies access tokens offline against GET /v1/auth/token/jwks — which is the fast, recommended pattern — then a revoked session’s access token keeps verifying until it expires. The signature is still valid; the clock hasn’t run out; your verifier has no way to know.

That’s not a flaw to route around. It’s the trade-off you took in exchange for zero-latency verification, and the fix is a policy decision:

ApproachRevocation takes effectCost
Offline verify onlywhen the access token expiresnone
Offline + online check on sensitive routesimmediately, where it mattersone round trip on those routes
Online check on every requestimmediatelya round trip per request
Deny-list revoked sid in your own cacheimmediatelya shared cache to run

Most teams pick row two. Keep access tokens short, then call GET /v1/auth/session/verify/{session_id} before anything destructive or financial.

The breach playbook, as a script

import os

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}


def containment(user_id: str, new_password: str, current_password: str) -> dict:
    """Rotate the credential, then close every session. Order matters: revoking
    first leaves a window in which the old password still works."""
    changed = requests.post(
        f"{API}/v1/auth/password/change",
        headers=HEADERS,
        json={"user_id": user_id, "current_password": current_password,
              "new_password": new_password},
        timeout=15,
    )
    changed.raise_for_status()

    revoked = requests.post(
        f"{API}/v1/auth/session/revoke_all_for_user/{user_id}",
        headers=HEADERS,
        json={"user_id": user_id},
        timeout=15,
    )
    revoked.raise_for_status()
    closed = revoked.json()["data"].get("count", 0)

    notice = requests.post(
        f"{API}/v1/email/send",
        headers=HEADERS,
        json={
            "to": changed.json()["data"]["email"],
            "subject": "Your password was changed and all devices were signed out",
            "body": "If this wasn't you, reply to this message immediately.",
        },
        timeout=20,
    )
    return {"sessions_closed": closed, "notified": notice.ok}


if __name__ == "__main__":
    print(containment(os.environ["USER_ID"], os.environ["NEW_PASSWORD"],
                      os.environ["OLD_PASSWORD"]))

Rotate, then revoke, then notify. Reversing the first two leaves a gap where the attacker’s password still works and their session was merely inconvenienced.

That third call is the argument for doing this on one platform at all: the notification isn’t a second vendor, a second key and a second bill — POST /v1/email/send is the same credential that just revoked the sessions, and if you’d rather text the user, POST /v1/sms/send is right there too.

Limitations

There’s no “revoke every session for every user” switch, so a platform-wide forced logout is a loop over your own user list with GET /v1/auth/user/list — workable, but you’re writing the fan-out and its rate limiting. There’s also no webhook telling your services a session died; if you want that, publish it yourself on POST /v1/realtime/publish and have your gateways subscribe.

Auth0 gives you organisation-wide session policies and a more complete audit trail out of the box, and for a security team that needs those as products rather than as endpoints, it’s the better fit.

Revoke, list and verify all report billing_class: free in discovery — containment costs nothing per call. Identity is metered by monthly active user, as with Clerk and Auth0, and GET /v1/account/usage is the live figure for your account (verified 2026-09-21). Read it there; platform rates trend downward and a number written into a guide only rots.

References

Browse more auth developer guides