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: Auth4BrowserError with code account_suspended from handleCallback() (hosted login redirect or code exchange) and from token renewal. Renewal also clears the session and emits signed_out with reason: "refresh_rejected".
  • Unity SDK: Auth4Exception.IsAccountSuspended. Device polling throws Auth4DeviceAuthorizationException with Reason == DeviceAuthorizationFailure.Suspended; refresh throws Auth4SessionExpiredException after clearing the local session.
  • Godot SDK: Auth4Result.reason == Auth4Result.SUSPENDED from 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 (fake Date), refresh account_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