Token Claims Policy
:::info Ratified This policy was ratified by the owner on 2026-10-17 (#789, epic #786). Shipped items are live in production today; Planned items are tracked as follow-up issues (#793, #794, #795) and are not yet live. :::
This page explains what Curupira puts in each token, why, and how a relying party (RP) should read authorization data without breaking on large tokens.
1. What each token carries (Shipped)
| Token | Purpose | roles | permissions |
|---|---|---|---|
id_token | Tell the RP who signed in. | never | never |
| Access token | Call APIs / UserInfo. | when the user has ≥1 role in the app | when the user's roles grant ≥1 permission |
/oauth2/userinfo response | Profile + authorization data, on demand. | always ([] when empty) | always ([] when empty) |
Rules that hold today:
- The
id_tokennever carriesrolesorpermissions. It is an identity token. - In tokens, an empty claim is omitted, never sent as
[](shipped in #788). Absent from a token means "none" unless the token carries an overage pointer for that claim (_claim_names, §5, planned in #795); then the data exists and must be fetched from UserInfo. The UserInfo response is different today: it always includesrolesandpermissions, as[]when empty. Treat absent and[]the same. permissionsis the deduplicated set of permission strings granted by the user's roles (direct and via role groups) for the requesting application only.- Tokens are re-derived on refresh, so role changes appear on the next refresh or login.
2. Guidance for relying parties
Read authorization data from UserInfo, not from the id_token.
- Verify the
id_tokensignature andiss/aud/exp/nonce; usesub(andemail) to identify the user. - Call
GET /oauth2/userinfowith the access token to getrolesandpermissions. - If your RP is an API (resource server), read
roles/permissionsfrom the verified access token. Treat a missing key as "none" only when_claim_names(once #795 ships) does not list that claim; if it does, fetch the full set from UserInfo.
The BookStack gotcha. Many OIDC clients (BookStack is the example that surfaced this) read the groups
claim from the id_token first and fall back to UserInfo only when the claim is absent (null). An
explicit "roles": [] in the id_token stops that fallback and the user lands with no roles. Curupira
omits the claim, so the native fallback fires and no RP-side patch is needed. If you operate another IdP
alongside Curupira that sends empty arrays, keep a defensive "treat empty as absent" patch in your RP.
Do not store tokens in cookies or forward them in headers without checking their size. Typical limits are 4 KB per cookie and 8 KB per request header line (nginx default). See §4.
3. How other identity providers handle it (prior art)
Details below are summarised from each vendor's public documentation; check the vendor docs for the current numbers.
- Auth0 — permissions in the access token are an opt-in setting per API ("Add Permissions in the Access Token"), with a documented warning about token size. Custom claims must be namespaced.
- Okta — a groups claim with a filter (equals / starts-with / contains / regex) selects which groups are emitted, and a hard cap on the number of groups applies; beyond it the claim must be resolved through the API.
- Microsoft Entra ID (Azure AD) — emits group IDs in the token up to a ceiling; above it the claim is
dropped and replaced by an overage pointer (
_claim_names/_claim_sources) telling the RP to call Microsoft Graph for the full set. - Keycloak / Authentik — omit the roles/groups claim entirely when empty (the behaviour Curupira now matches).
Curupira's policy composes two of these mechanisms. It deliberately does not copy Auth0's opt-in toggle:
permissions stay in the access token for every application.
| Axis | Mechanism | Modelled on |
|---|---|---|
| Which entries are in the token | per-application filter | Okta |
| What happens when it is still too big | overage pointer to UserInfo | Entra ID |
4. Size budget
Measured on the current claim set (RS256, 2048-bit key = 342-char signature, 4 roles, permissions of the
form orders.resource042.read, ~22 chars):
| Permissions in token | Encoded token size |
|---|---|
| 0 | ≈ 1.1 KB |
| 20 | ≈ 1.8 KB |
| 50 | ≈ 2.8 KB |
| 100 | ≈ 4.6 KB |
| 200 | ≈ 8.0 KB |
| 400 | ≈ 15.0 KB |
Each ~22-character permission costs about 35 bytes in the encoded token. A token past ~8 KB breaks
behind a default nginx (large_client_header_buffers 8 KB per header line); past ~4 KB it does not fit a
browser cookie. These failures are intermittent and appear only for the most privileged users.
5. Ratified policy
scope,rolesandpermissionsare always in the access token (no per-application toggle).- An optional per-application claim filter, configured on the application, limits which roles and permissions are emitted (Planned, #794). It applies to both the access token and UserInfo.
- A ceiling protects against oversized tokens (Planned, #795): the combined JSON of
roles+permissionsmay be at most 2048 bytes and 100 entries. Order of evaluation: filter, then ceiling. On breachpermissionsis dropped first, thenrolesif still too large. A list is never truncated. Both limits are server constants, overridable by environment. - A dropped claim is replaced by a pointer in the access token (never in the
id_token):
{
"_claim_names": { "permissions": "src1" },
"_claim_sources": { "src1": { "endpoint": "https://auth.curupira.api.br/oauth2/userinfo" } }
}
An RP that sees _claim_names must call UserInfo for the full set. UserInfo is not subject to the
ceiling, but is subject to the application's filter. The endpoint comes from the configured issuer.
UserInfo currently copies roles / permissions from the access token, so it must first resolve them
from the database itself; this is tracked in #793 and is a prerequisite of the ceiling (#795).