Custom claims
FerrisKey’s default scopes cover the standard OIDC claims. Real applications need more: roles a gateway can enforce, a tenant identifier, a plan tier, feature flags. Custom claims put exactly that data in the token so your backend decides without a second call.
Every config below uses the dotted Keycloak-style keys the mappers actually read. See Protocol Mappers for the full key reference.
Realm roles for an API gateway
Your gateway enforces role-based access on routes. The realm role mapper already writes roles into the token; the only decision is where.
The scope is roles, a default scope seeded in every realm, and the mapper is oidc-usermodel-realm-role-mapper.
{
"claim.name": "realm_access.roles",
"access.token.claim": "true",
"id.token.claim": "false"
}
{
"realm_access": {
"roles": ["admin", "editor", "viewer"]
}
}
The gateway reads realm_access.roles and allows or denies the route without calling back to FerrisKey. Setting id.token.claim to false keeps roles out of the ID token, which the browser can read. The gateway is the only party that needs them.
Flat claim instead of nested
claim.name supports dotted paths, so realm_access.roles nests. If your gateway expects a flat array under roles, set claim.name to roles instead. The default is the nested form because that is what Keycloak-compatible libraries look for.
Claim names have limits
A claim name is a dotted path of at most 8 non-empty segments and at most 255 characters. The API rejects a longer claim.name or token.claim.name with a 422 invalid_claim_name when you create or update the mapper. A name that still gets through, such as a long user.attribute that a mapper falls back on when it has no claim.name, is dropped silently at token time. The mapper looks configured and the claim never shows up.
Tenant identifier for a multi-tenant backend
Each realm is a tenant, and the backend needs to know which tenant database to query.
The scope is a tenant scope assigned as default, and the mapper is oidc-hardcoded-claim-mapper.
{
"claim.name": "tenant",
"claim.value": "acme-corp",
"jsonType.label": "String",
"access.token.claim": "true",
"id.token.claim": "false"
}
Since the value is realm-specific, you create this scope in each realm with the right value.
When the tenant is per-user, not per-realm
Store it as a user attribute and use oidc-usermodel-attribute-mapper with "user.attribute": "tenant" instead. And if tenancy is modelled with the Organizations module, skip both: oidc-organization-membership-mapper writes the user’s organizations, by alias, with no per-realm setup. The seeded organization scope already does it.
A user attribute as a claim
Use oidc-usermodel-attribute-mapper to carry business data that lives on the user, such as a plan tier or a seat limit.
{
"user.attribute": "seat_limit",
"claim.name": "seat_limit",
"access.token.claim": "true",
"id.token.claim": "false"
}
{
"seat_limit": "25"
}
User attributes are stored as strings and this mapper emits them as strings. It does not read jsonType.label, so a number or a boolean arrives as text, and the consumer has to parse it. The cast exists on oidc-usermodel-property-mapper and oidc-hardcoded-claim-mapper, not here.
Audience for several resource servers
Your tokens have to be accepted at both api.yourapp.com and admin.yourapp.com. Add one mapper per audience, using oidc-audience-mapper twice.
{
"included.client.audience": "api.yourapp.com",
"access.token.claim": "true",
"id.token.claim": "false"
}
{
"included.client.audience": "admin.yourapp.com",
"access.token.claim": "true",
"id.token.claim": "false"
}
{
"aud": ["acme-realm", "account", "api.yourapp.com", "admin.yourapp.com"]
}
The access token’s aud always starts with the realm audience (<realm_name>-realm) and account, and the mappers append to it. A token request that carries a resource parameter replaces the whole list with that resource.
aud needs this mapper specifically
aud is a reserved claim name: it is stripped after mappers run, so a hardcoded claim mapper pointed at aud silently does nothing. The audience mapper contributes audiences through a separate channel, which is why it works. That channel only feeds the access token. The ID token’s aud is always the client’s own client_id.
Feature flags in the ID token
The frontend decides which features to render, so the flags belong in the ID token it can read, not in the access token the API validates.
The scope is a features scope assigned as optional, so only the clients that ask for it pay the token size. The mapper is oidc-hardcoded-claim-mapper.
{
"claim.name": "features.new_dashboard",
"claim.value": "true",
"jsonType.label": "Boolean",
"access.token.claim": "false",
"id.token.claim": "true"
}
{
"features": {
"new_dashboard": true
}
}
The dotted claim.name nests each flag under one features object, so a second mapper writing features.beta_export merges into the same object instead of overwriting it.
The client has to name features in its scope for an optional scope to apply, and the request must include openid: FerrisKey only builds ID-token claims from mappers when openid is in the scope.
Group membership for a hierarchy-aware app
The mapper is oidc-group-membership-mapper.
{
"claim.name": "groups",
"membership": "direct",
"full.path": "true",
"access.token.claim": "true",
"id.token.claim": "false"
}
direct emits only the groups the user is a direct member of. The default, effective, also emits every ancestor path. Pick that one when the consumer checks for a parent group without reconstructing the hierarchy itself, and direct when you want smaller tokens and less org structure in them.
Scope assignment strategy
| Assign as default when | Assign as optional when |
|---|---|
| Every request from this client needs the claim | The claim carries sensitive data |
| The claim is needed for an authorization decision | The claim is large and would bloat every token |
| Missing the claim would break the client | Only some flows need the data |
Before you debug the claim, check the key
A mapper whose config key is misspelled is accepted by the API and then ignored at token time. The config field is free-form JSON, and the only thing validated is the shape of claim.name. The same goes for a mapper_type that is not one of the eleven types: the API stores it and the token builder skips it with a warning in the server log. When a claim does not show up:
- Check the
mapper_typeand the keys. They are the dotted ones (claim.name, notclaim_name). - Check the claim name is not reserved, and that it stays within 8 segments and 255 characters.
- Check the scope is actually active on the client, default or requested by name, and that
openidis in the request if the claim belongs in the ID token. - Use
POST /realms/{realm_name}/clients/{client_id}/evaluate-scopeswith auser_idand an optionalscopeto see the claims a client would get, without a full login. The console has the same preview under Evaluate on the client’s Client Scopes tab.