AUTH4D-154 custom claims and player roles

Project developers define roles per application, assign them to players, and give each player an optional metadata object. Access tokens carry them in two namespaced claims. The management routes are on {AUTH_ORIGIN} and are implemented in apps/api/src/modules/claims. Wire shapes are the zod schemas in packages/contracts (ApplicationRole*, UserClaims*, UpdateUserRolesRequestSchema, UpdateUserMetadataRequestSchema); claim rules and limits are in custom-claims.ts (ROLES_CLAIM, METADATA_CLAIM, CUSTOM_CLAIM_LIMITS, RESERVED_CLAIM_NAMES). Storage is migration 0018_custom_claims.sql. Responses are JSON with Cache-Control: no-store. Errors use {"error":{"code","message","request_id"}}.

Access-token claims

{
  "sub": "usr_…",
  "aud": "cli_…",
  "client_id": "cli_…",
  "token_use": "access",
  "auth4_roles": ["moderator", "vip"],
  "auth4_meta": { "tier": "gold", "level": 7 }
}
  • auth4_roles lists the subject’s roles for the token’s application (client_id), sorted and unique, 1 to 20 names. Roles assigned in another application of the same project are never included.
  • auth4_meta is the subject’s metadata: a flat object of 1 to 32 keys and at most 2048 bytes of UTF-8 JSON. Values are strings (at most 256 characters), finite numbers, booleans or null.
  • Each claim is absent when there is nothing to send. Auth4 never sends [] or {}. The contract schema rejects empty, duplicate or malformed values, so a token with them is invalid.
  • Both are read from D1 (primary, tenant-scoped, loadD1CustomClaims in packages/sessions) every time an access token is signed: POST /t/{tenantId}/guest, guest upgrade, and POST /oauth/token with authorization_code, device_code or refresh_token. A change reaches the next token, including the next refresh. Tokens already issued keep their values until they expire (at most five minutes). A failed lookup returns the endpoint’s usual user_unavailable error and does not rotate the presented refresh token. A database that has not applied migration 0018 issues tokens without the claims.
  • ID tokens and UserInfo are unchanged and never carry these claims.

Reserved claim names

Customers can never set or shadow a top-level claim. Roles and metadata are always nested under the two fixed names above, and the strict access-token schema rejects any other unknown claim. Metadata keys must match ^[a-z][a-z0-9_]{0,39}$ and cannot be one of iss, sub, aud, exp, iat, nbf, jti, azp, auth_time, nonce, acr, amr, sid, scope, client_id, tenant_id, token_use, email, email_verified, or start with auth4_. A stored row that breaks these rules (for example one written directly to D1) is dropped at issuance instead of emitted.

Authorization

Each route is checked in this order:

  1. Management token (401). The AUTH4D-17 verifier with the route’s scope. Customer-realm tokens never authorize these routes.
  2. Path shape (404). Tenant (ten_*), client (cli_*), user (usr_*) IDs and role names (^[a-z][a-z0-9_.-]{0,63}$) must match their contract formats.
  3. Per-actor budget (429). 120 requests per actor per minute (managementRequestsPerActor).
  4. Live membership in an active customer project (403 forbidden). Unknown projects and other developers’ projects look the same.
  5. Project role (403 insufficient_role). The role must hold the route’s scope (owners and admins hold client:write and user:write; viewers only read). Denied mutations are written to the project audit log with outcome denied.
Route (under /management/tenants/{tenantId}) Scope Who
GET /roles client:read any member
PUT /clients/{clientId}/roles/{roleName} client:write owner, admin
DELETE /clients/{clientId}/roles/{roleName} client:write owner, admin
GET /users/{userId}/claims user:read any member
PUT /users/{userId}/roles user:write owner, admin
PUT /users/{userId}/metadata user:write owner, admin

The console BFF forwards these paths unchanged. DELETE requires Content-Type: application/json and the body {}.

Role definitions

GET /roles

Optional query: client_id=cli_…. Any other or repeated parameter is 400 invalid_request.

{
  "roles": [
    {
      "clientId": "cli_…",
      "name": "moderator",
      "description": "Can mute players",
      "assignedUsers": 3,
      "createdAt": "2026-10-06T08:00:00.000Z",
      "updatedAt": "2026-10-06T08:00:00.000Z"
    }
  ]
}

Ordered by clientId, then name.

PUT /clients/{clientId}/roles/{roleName}

Body: {"description":"…"} (optional, at most 200 characters, no control characters). Creates the role (201) or updates its description (200). Repeating the current definition is a no-op with no audit event. Returns {"role":{...}}.

Status Code When
400 validation_failed Invalid body; details[] lists {field, issue}.
404 not_found Invalid role name, or no such application in this project.
409 role_limit_reached The application already has 50 roles.

DELETE /clients/{clientId}/roles/{roleName}

Deletes the role and, in the same transaction, every assignment of it. Returns {"deleted":true,"removedAssignments":3}; 404 when it does not exist. Deleting an application deletes its roles too.

User claims

{userId} must be an end user of the project; team members’ own project rows are 404.

GET /users/{userId}/claims

{
  "claims": {
    "userId": "usr_…",
    "roles": [{ "clientId": "cli_…", "name": "vip" }],
    "metadata": { "tier": "gold" },
    "metadataUpdatedAt": "2026-10-06T08:00:00.000Z"
  }
}

metadata is {} and metadataUpdatedAt is null when the user has none.

PUT /users/{userId}/roles

Body: {"roles":[{"clientId":"cli_…","name":"vip"}]}. Replaces the user’s whole role set across all applications; [] removes every role. At most 20 roles per application and 200 in total, with no duplicates (400 validation_failed). Every role must already be defined for its application in this project (400 unknown_role). If a role is deleted while the change is saved, nothing is written and the response is 409 roles_changed. An unchanged set writes nothing and adds no audit event. Returns {"claims":{...}}.

PUT /users/{userId}/metadata

Body: {"metadata":{...}} following the rules above; {} removes the metadata. Invalid keys, reserved names, nested values or oversized objects are 400 validation_failed. Unchanged metadata writes nothing. Returns {"claims":{...}}.

Audit events

Every change is recorded in the project audit log, in the same D1 batch as the change. Metadata values are never audited, only their shape.

Event Target Metadata
management.role.created role {clientId}/{roleName} actor_role, client_id, role
management.role.updated role {clientId}/{roleName} actor_role, client_id, role
management.role.deleted role {clientId}/{roleName} actor_role, client_id, role, removed_assignments
management.user.roles_updated user {userId} actor_role, roles_added, roles_removed, roles_total, added, removed
management.user.metadata_updated user {userId} actor_role, keys, bytes, cleared

added and removed are space-separated {clientId}/{roleName} lists, cut to 256 characters.

Storage

Migration 0018_custom_claims.sql adds three tables, each keyed by tenant_id first with every foreign key including it:

  • application_roles (tenant_id, client_id, role_name, description, created_at, updated_at), cascading from clients.
  • user_application_roles (tenant_id, user_id, client_id, role_name, assigned_at), cascading from users and from application_roles.
  • user_claim_metadata (tenant_id, user_id, metadata, updated_at), cascading from users. A CHECK keeps metadata a non-empty JSON object of at most 2048 bytes.

User deletion (AUTH4D-27) and retention remove a user’s assignments and metadata through these cascades.

Console

The user detail page (Users, then a user) has a Roles and metadata section: checkboxes per application to assign roles, a JSON editor for metadata, and Role definitions to define and delete roles. Editing is shown to project owners; other members see the values read-only.

Server SDK

@auth4/sdk-server adds roles: readonly string[] and metadata to VerifiedAccessTokenClaims (Auth4RoleClaims, Auth4MetadataClaims). Both are empty when the claim is absent. hasRole(claims, role) and hasAnyRole(claims, roles) compare names exactly; an empty list never matches.

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