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_roleslists 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_metais 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 ornull.- 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,
loadD1CustomClaimsinpackages/sessions) every time an access token is signed:POST /t/{tenantId}/guest, guest upgrade, andPOST /oauth/tokenwithauthorization_code,device_codeorrefresh_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 usualuser_unavailableerror 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:
- Management token (401). The AUTH4D-17 verifier with the route’s scope. Customer-realm tokens never authorize these routes.
- 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. - Per-actor budget (429). 120 requests per actor per minute (
managementRequestsPerActor). - Live membership in an active customer project (403
forbidden). Unknown projects and other developers’ projects look the same. - Project role (403
insufficient_role). The role must hold the route’s scope (owners and admins holdclient:writeanduser:write; viewers only read). Denied mutations are written to the project audit log with outcomedenied.
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 fromclients.user_application_roles (tenant_id, user_id, client_id, role_name, assigned_at), cascading fromusersand fromapplication_roles.user_claim_metadata (tenant_id, user_id, metadata, updated_at), cascading fromusers. A CHECK keepsmetadataa 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