AUTH4D-310 Timed player bans
A project owner can ban a player for a set time or permanently, with an internal reason. A ban
signs the player out everywhere, like disabling them (AUTH4D-19 (repository file docs/api-spec/auth4d-19.md)), and refuses
sign-in, token refresh and device approval until it ends. An expired ban stops applying on its
own; no job runs. Bans are kept after they end, so each player has a ban history.
Disabling is unchanged and separate. A disabled player stays disabled whatever their bans say, and lifting a ban never re-enables them.
What the player sees
A banned player gets one error code, account_suspended, and nothing else about the ban: not the
reason, not who banned them and not when the ban ends.
| Where | Answer |
|---|---|
Hosted login, POST /oauth/authorize/continue |
JSON mode: 200 with redirect_to (the redirect URI with error=account_suspended and state) and denial: { "message": "account_suspended" }. Hosted login shows “Your account is suspended”. |
| Hosted login, verified email step resume | The same denial |
prompt=none |
error=login_required, so the game falls back to interactive sign-in, which explains the suspension |
POST /oauth/token, authorization_code |
400 {"error":"account_suspended"}. A code issued just before the ban is spent without tokens |
POST /oauth/token, refresh_token |
Creating a ban ends every refresh family, so an old token gets 400 invalid_grant. A family that outlived the revocation gets 400 {"error":"account_suspended"} and is ended |
POST /oauth/token, device code grant |
400 {"error":"account_suspended"} if the player was banned after approving; the device code is spent |
Device approval (POST /device/approval) |
403 {"error":"account_suspended"}; the user code stays pending and is never bound to the banned player |
| Guest restore, claim and upgrade | No new session is created |
GET /oauth/userinfo, POST /oauth/introspect |
401 invalid_token and {"active": false} |
Access tokens issued before the ban stay valid until they expire, for at most 5 minutes, unless the game’s server introspects them or checks session state online. This is the same window as for disable and session revocation.
SDKs
- Browser SDK:
Auth4BrowserErrorwith codeaccount_suspendedfromhandleCallback()(hosted login redirect or code exchange) and from token renewal. Renewal also clears the session and emitssigned_outwithreason: "refresh_rejected". - Unity SDK:
Auth4Exception.IsAccountSuspended. Device polling throwsAuth4DeviceAuthorizationExceptionwithReason == DeviceAuthorizationFailure.Suspended; refresh throwsAuth4SessionExpiredExceptionafter clearing the local session. - Godot SDK:
Auth4Result.reason == Auth4Result.SUSPENDEDfrom device polling and refresh; refresh clears the local session.
Show the player that their account is suspended and, if you want, how to contact your support team.
Management API
All routes need a management token. Reads need user:read; changes need user:write and the
project owner role (viewers get 403 insufficient_role, audited as denied). A developer without
access to the project gets 403 forbidden, and a user, ban or path from another project gets
404 not_found. Every query filters on tenant_id and user_id. Responses are
Cache-Control: no-store. Wire shapes are in packages/contracts/src/bans.ts.
Ban object
{
"id": "ban_3hT0bq7WcYk2Rr9FzLmNpQ1a",
"user_id": "usr_...",
"status": "active",
"reason": "Chargeback abuse, ticket 4411",
"created_at": "2026-10-22T15:00:00.000Z",
"created_by": "usr_...",
"updated_at": "2026-10-22T15:00:00.000Z",
"expires_at": "2026-10-22T16:00:00.000Z",
"revoked_at": null,
"revoked_by": null
}
status is active while the ban applies, expired once expires_at has passed and revoked
once it was lifted. expires_at is null for a permanent ban. reason is the team’s internal
note (up to 500 characters, no control characters other than tabs and newlines); it is only ever
returned here.
List a player’s bans
GET /management/tenants/{tenantId}/users/{userId}/bans
200 { "bans": [Ban, ...], "active_ban": Ban | null }, newest first, at most 100.
Ban a player
POST /management/tenants/{tenantId}/users/{userId}/bans
{ "reason": "Chargeback abuse, ticket 4411", "duration_seconds": 3600 }
| Field | Required | Notes |
|---|---|---|
reason |
No | Internal note, default "" |
duration_seconds |
No | 60 seconds to ten years, counted from now |
expires_at |
No | An ISO date-time in the future, at most ten years away |
Give duration_seconds or expires_at, not both; give neither for a permanent ban. The ban is
written first, so new sessions and refresh are refused at once, and then every session and refresh
family of the player is revoked (the AUTH4D-158 session.revoked event is published as for
disable).
201 { "ban": Ban, "revoked_sessions": 1 }. Errors: 400 invalid_request, 409 ban_active when a
ban already applies (change or lift it instead), 409 too_many_bans past 100 bans for one player,
503 session_state_unavailable when session storage is down (nothing is written).
Change an active ban
PATCH /management/tenants/{tenantId}/users/{userId}/bans/{banId}
{ "duration_seconds": 86400 }
Any of reason, duration_seconds (counted from now) and expires_at (null makes it permanent).
At least one is required, and duration_seconds and expires_at can’t be combined. 200 { "ban": Ban }. A revoked or expired ban is history and gets 409 ban_inactive; ban the player again
instead.
Lift a ban
POST /management/tenants/{tenantId}/users/{userId}/bans/{banId}/revoke with body {}
200 { "ban": Ban, "revoked": true }; revoked is false when it was already lifted. The player
can sign in again at once unless they are disabled or another ban applies. Sessions that ended when
the ban started stay ended.
Audit
| Action | Event | Target | Metadata |
|---|---|---|---|
| Ban | management.user.banned |
ban ban ID |
user_id, permanent, expires_at, revoked_sessions |
| Change | management.user.ban_updated |
ban ban ID |
user_id, permanent, expires_at, reason_changed |
| Lift | management.user.ban_revoked |
ban ban ID |
user_id, already_revoked |
| Owner check | the same event, denied |
user |
actor_role |
The reason is never written to the audit log. No webhook event is added for bans: adding a Flows
event type is not additive for existing consumers (see the AUTH4D-301 lesson in AGENTS.md), so
it is left as follow-up work.
Storage
Migration 0041_user_bans.sql adds user_bans (tenant_id, ban_id, user_id, reason,
created_at, created_by, updated_at, expires_at, revoked_at, revoked_by), keyed by
(tenant_id, ban_id) with a composite foreign key to users that deletes bans with their player.
It is additive. Code running on a database without the table treats every player as not banned.
Console
The user screen has a Bans section above the identities: the current ban (or “not banned”), Ban user with a length (1 hour, 1 day, 7 days, 30 days or permanent) and an internal reason, and the ban history with Change and Lift for the active ban. Viewers see the history without the controls.
Tests
tests/integration/auth4d-310.bans.test.ts: a one-hour ban blocks sign-in and refresh and stops at its end (fakeDate), refreshaccount_suspended, a code issued before the ban, lifting a ban, disable unchanged, validation and roles, tenant isolation, and a scan of every end-user response for the reason.tests/e2e/auth4d-310.bans.spec.ts(auth4d-310.playwright.config.ts, port 5310): banning, changing and lifting from the console at 320px and 1280px, and viewer access.
Source: docs/api-spec/auth4d-310.md