AUTH4D-324 Player names

Players can have a public display name for leaderboards and lobbies. auth4.dev adds a random four-digit discriminator, so the full name, Name#1234, is unique in the project. Names are separate per project: the same Name#1234 can exist in two projects.

Name rules

  • The server trims the name and normalises it to Unicode NFC.
  • 1 to 32 characters (Unicode code points) after that.
  • No spaces or other white space, no control or format characters (such as zero-width spaces) and no #.
  • Uniqueness ignores case and compatibility forms: Ace#0427 and ace#0427 can’t both exist.
  • The discriminator is 0001 to 9999. Changing only the case of a name keeps the discriminator; any other change gets a new random free one.
  • A project can block words. A name that contains a blocked word anywhere, ignoring case, is refused. It gets the same invalid_name error as a name that breaks a rule, and refused attempts count against the player’s change budget, so the list can’t be read out quickly.

Player endpoints

GET /t/{tenantId}/me/name reads the signed-in player’s name. PUT on the same path sets it:

{ "name": "Ace" }

Send { "name": null } to clear it. Both answer 200 with the stored name:

{ "name": "Ace", "discriminator": "0427", "tag": "Ace#0427" }

All three fields are null until a name is set. Responses are Cache-Control: no-store.

Both need the player’s access token in Authorization: Bearer …, from any application type and any sign-in method, guests included. The token is never a cookie, so another site can’t make the call for the player (CSRF). A browser caller must use one of the token’s application’s registered origins; the preflight admits GET and PUT with Authorization and Content-Type for an origin registered on any enabled browser application of the project.

Status error When
400 invalid_name The name breaks a rule or contains a blocked word
400 invalid_request The body is not { "name": string | null }
401 invalid_token No token, an expired or revoked one, one from another project, or a ban
403 access_denied The browser origin is not registered on the token’s application
409 name_unavailable Every discriminator for this name is taken
429 rate_limited More than 10 attempts by this player in an hour
503 temporarily_unavailable Storage is unavailable, or migration 0045_player_names.sql is not applied

A change writes the audit event player.name.updated (actor and target: the player) with action set or cleared and the application’s client_id. The name itself is not in the audit row.

Claims

  • Userinfo returns name (Name#1234) when the access token has the profile scope and the player has a name. Without the profile scope it is never returned.
  • Access tokens carry the same name claim only when the access token has the profile scope and the project turns on access_token_claim (below). It is off by default because server SDKs released before AUTH4D-324 refuse access tokens with unknown claims; update them first. The claim is read again at every issuance, including refresh, so a rename shows in the next token.
  • ID tokens never carry it.

Management

These routes live under /management/tenants/{tenantId}. Viewers can read; owners and admins can change. Errors use the management envelope (error.code).

GET /management/tenants/{tenantId}/users/{userId}/name (user:read) returns:

{
  "player_name": {
    "name": "Ace",
    "discriminator": "0427",
    "tag": "Ace#0427",
    "updated_at": "2026-10-24T12:00:00.000Z"
  }
}

PUT on the same path (user:write) overrides the name:

{ "name": "Ace", "discriminator": "0001" }

name follows the name rules, or is null to clear it. discriminator is optional; without it the player keeps their current one when the name is unchanged ignoring case, and gets a free one otherwise. Blocked words don’t apply to an override. A change writes management.user.name_updated with action set or cleared.

GET /management/tenants/{tenantId}/player-names (user:read) returns the project setting:

{ "player_names": { "blocked_words": ["grief"], "access_token_claim": false } }

PUT on the same path (user:write) takes either field or both. blocked_words replaces the list: up to 200 words of 1 to 32 characters with no spaces; repeats that differ only in case are stored once. A change writes management.player_names.updated with blocked_word_count and status (enabled or disabled for the access-token claim). The words are not in the audit row.

Status error.code When
400 validation_failed A malformed name, discriminator, word list or access_token_claim
403 forbidden The project is unknown or belongs to someone else
403 insufficient_role A viewer tries to make a change
404 not_found The user is not in this project
409 name_unavailable The requested Name#NNNN belongs to another player, or none is free
409 project_suspended The project is suspended
503 not_ready Migration 0045_player_names.sql has not been applied

Storage

Migration 0045_player_names.sql adds users.display_name, display_name_key (the NFKC, lower-cased comparison form), display_name_discriminator and display_name_updated_at, a unique index on (tenant_id, display_name_key, display_name_discriminator), and the player_name_settings table (one optional row per project). Code running before the migration issues tokens and userinfo without a name.

SDKs

  • Browser: getPlayerName() and setPlayerName(name | null) resolve with { name, discriminator, tag } and reject with invalid_name, name_unavailable, invalid_session or player_name_failed. getUser() includes name when userinfo returns it.
  • Unity: GetPlayerNameAsync() and UpdatePlayerNameAsync(name) return a PlayerName (Name, Discriminator, Tag, IsSet), like Unity Authentication’s methods of the same names.
  • Server SDKs (TypeScript, C#, Go, Python) accept the name access-token claim.

Source: docs/api-spec/auth4d-324.md