Skip to main content

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)​

TokenPurposerolespermissions
id_tokenTell the RP who signed in.nevernever
Access tokenCall APIs / UserInfo.when the user has ≥1 role in the appwhen the user's roles grant ≥1 permission
/oauth2/userinfo responseProfile + authorization data, on demand.always ([] when empty)always ([] when empty)

Rules that hold today:

  • The id_token never carries roles or permissions. 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 includes roles and permissions, as [] when empty. Treat absent and [] the same.
  • permissions is 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.

  1. Verify the id_token signature and iss / aud / exp / nonce; use sub (and email) to identify the user.
  2. Call GET /oauth2/userinfo with the access token to get roles and permissions.
  3. If your RP is an API (resource server), read roles / permissions from 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.

AxisMechanismModelled on
Which entries are in the tokenper-application filterOkta
What happens when it is still too bigoverage pointer to UserInfoEntra 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 tokenEncoded 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, roles and permissions are 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 + permissions may be at most 2048 bytes and 100 entries. Order of evaluation: filter, then ceiling. On breach permissions is dropped first, then roles if 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).