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:
| Approach | Revocation takes effect | Cost |
|---|---|---|
| Offline verify only | when the access token expires | none |
| Offline + online check on sensitive routes | immediately, where it matters | one round trip on those routes |
| Online check on every request | immediately | a round trip per request |
Deny-list revoked sid in your own cache | immediately | a 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.