Protocol mappers
Protocol mappers are the rules that turn user, group, organization and client data into JWT claims. Each mapper belongs to a client scope and runs during token generation.
A mapper is identified by a mapper_type string and carries a JSON config. FerrisKey keeps the Keycloak names for both, so configuration written for Keycloak is recognizable here: keys are dotted, and their values are strings, including the booleans. FerrisKey runs eleven mapper types, listed below.
Keys are dotted, not snake_case
The config keys are claim.name, access.token.claim and user.attribute, not claim_name, add_to_access_token or attribute. The config field is free-form JSON, so the API accepts an unknown key without complaining and the mapper then ignores it. A mapper that silently does nothing, or writes to a claim you did not name, is almost always a key spelled the wrong way.
The same goes for mapper_type. The API stores any non-empty string, and at token time FerrisKey skips a type it does not run and logs a warning. The console’s mapper catalog lists oidc-address-mapper, oidc-hardcoded-role-mapper and oidc-role-name-mapper, and its forms offer Keycloak fields such as userinfo.token.claim, introspection.token.claim, multivalued, claim.value.type and included.custom.audience. FerrisKey runs none of those types and reads none of those keys.
Keys every mapper reads
| Config key | Default | Description |
|---|---|---|
claim.name | per mapper | Target claim. Dotted paths nest, so realm_access.roles produces {"realm_access": {"roles": [...]}} rather than a flat key with a dot in it |
access.token.claim | true | Include the claim in the access token. A boolean, or the string true. Any other string counts as false |
id.token.claim | true | Include the claim in the ID token. Same values |
Both token flags default to true when the key is absent, so a mapper with no flags writes to both tokens. The client role mapper, the full-name mapper and the audience mapper read the two flags but not claim.name.
token.claim.name is accepted as a legacy alias for claim.name by the property, attribute, realm role and hardcoded claim mappers, because some earlier consoles stored mappers that way. The group and organization mappers read only claim.name. Prefer claim.name in anything new.
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. At token time a name outside that shape is dropped silently, with only a warning in the server log.
Mapper types
oidc-usermodel-property-mapper
Maps a built-in user field to a claim.
| Config key | Description |
|---|---|
user.attribute | Field to read: username, email, firstName, lastName, or emailVerified. The spellings firstname, lastname and email_verified are also accepted |
claim.name | Target claim. Defaults to the value of user.attribute |
jsonType.label | Cast the value: String (default), Boolean, or Integer. Long, int, long and boolean are accepted too. A value that cannot be cast stays as it is |
{
"user.attribute": "username",
"claim.name": "preferred_username",
"jsonType.label": "String",
"access.token.claim": "true",
"id.token.claim": "true"
}
oidc-full-name-mapper
Writes the user’s first and last name, trimmed and joined with a space, into the name claim.
The claim name is fixed: this mapper does not read claim.name. It emits nothing when both names are empty. Its only keys are the two token flags.
{
"access.token.claim": "false",
"id.token.claim": "true"
}
The seeded profile scope carries given_name and family_name but no name, so add this mapper to a scope if a client expects it.
oidc-usermodel-attribute-mapper
Maps a custom user attribute to a claim. Use it for business data: department, plan tier, feature flags.
| Config key | Description |
|---|---|
user.attribute | Attribute key on the user. The mapper emits nothing when the user has no such attribute |
claim.name | Target claim. Defaults to the value of user.attribute |
The value is always a string. This mapper does not read jsonType.label, so a numeric attribute reaches the token as text.
{
"user.attribute": "department",
"claim.name": "department",
"access.token.claim": "true",
"id.token.claim": "true"
}
oidc-usermodel-realm-role-mapper
Writes the user’s realm role names into the token. Roles the user inherits from groups are included.
| Config key | Description |
|---|---|
claim.name | Target claim. Defaults to realm_access.roles |
{
"claim.name": "realm_access.roles",
"access.token.claim": "true",
"id.token.claim": "true"
}
{
"realm_access": {
"roles": ["admin", "user-manager", "viewer"]
}
}
oidc-usermodel-client-role-mapper
Writes client-scoped roles under resource_access.<client_id>.roles. The placement is fixed: this mapper does not take a claim.name.
| Config key | Description |
|---|---|
client.id | The client_id string of the one client whose roles to include. Left empty or absent, roles from every client are included |
{
"resource_access": {
"backend": { "roles": ["read", "write"] }
}
}
oidc-group-membership-mapper
Writes the groups the user belongs to.
| Config key | Default | Description |
|---|---|---|
claim.name | groups | Target claim |
membership | effective | effective includes every group plus all its ancestors, so a member of /novotel/EN/leaf gets all three paths. direct includes only groups the user is a direct member of, which matches Keycloak, keeps tokens smaller, and discloses less of the org structure |
full.path | true | Emit full paths (/parent/child). Set to false for bare group names |
prefix.org | false | Prepend the group’s organization alias (/acme/finance) |
prefix.org exists because a FerrisKey token can carry groups from several organizations at once, and paths are relative to their org, so /finance from two organizations would collide. It only applies to full paths.
oidc-organization-membership-mapper
Writes the organizations the user belongs to, as a JSON object keyed by alias. Keying by alias means a consumer can look up one organization without scanning an array.
| Config key | Default | Description |
|---|---|---|
claim.name | organizations | Target claim |
include.domain | false | Add each organization’s domain, null when it has none |
include.attributes | false | Add each organization’s attributes |
id, name and alias are always present. Domain and attributes are opt-in so tokens stay compact by default. A user with no organization gets an empty object.
oidc-organization-detail-mapper
Writes detail about a single organization.
| Config key | Default | Description |
|---|---|---|
claim.name | organization | Target claim |
organization.alias | unset | Only emit a claim if the user belongs to this organization. Omitted, the first organization in the user’s membership list is used |
include.attributes | false | Add the organization’s attributes |
id, name, alias and domain are always present, and domain is null when the organization has none. If the user belongs to no matching organization, the mapper emits nothing.
oidc-organization-role-mapper
Writes the roles a user holds within the scope of each organization, keyed by organization alias. Realm roles land under roles, client roles under clients.<client_id>.roles. An organization where the user holds no scoped role is left out.
| Config key | Default | Description |
|---|---|---|
claim.name | organizations | Target claim |
Org-scoped roles come from two sources, merged during token assembly: roles assigned directly to the membership, and roles inherited from the user’s groups in that organization.
This mapper shares a claim with the membership mapper
Both default to the organizations claim, and their objects deep-merge when both are enabled: the membership mapper contributes id, name and alias, this one contributes roles and clients. Match on the membership mapper’s id field rather than the alias, which can be renamed.
oidc-audience-mapper
Adds an entry to the audience (aud) claim of the access token. Use it when a token has to be accepted by more than one resource server.
| Config key | Description |
|---|---|
included.client.audience | Audience value to add. Any string works, not only a client_id. Empty or absent, the mapper emits nothing |
{
"included.client.audience": "another-client",
"access.token.claim": "true",
"id.token.claim": "false"
}
The access token’s aud starts as ["<realm_name>-realm", "account"] and the mapper appends to it. A token request with a resource parameter replaces the whole list with that resource. The ID token’s aud is the client’s client_id and no mapper changes it.
oidc-hardcoded-claim-mapper
Injects a static value. Useful for environment indicators or tenant identifiers that do not come from user data.
| Config key | Description |
|---|---|
claim.name | Target claim. The mapper emits nothing without one |
claim.value | Static value to inject |
jsonType.label | Cast the value: String (default), Boolean, Integer, Long or JSON. The lowercase boolean, int and long work too. JSON parses the value as JSON. A number or JSON that cannot be parsed stays a string, and a Boolean is true only for the exact string true |
{
"claim.name": "environment",
"claim.value": "production",
"jsonType.label": "String",
"access.token.claim": "true",
"id.token.claim": "false"
}
Reserved claim names
Ten claim names are removed after every mapper has run, so a mapper cannot overwrite them:
sub iat jti iss typ azp aud scope exp client_id
Target one of these and the mapper appears to be configured correctly yet changes nothing. aud is on the list, which is exactly why adding an audience needs oidc-audience-mapper, which contributes audiences through a separate channel, and not a hardcoded mapper pointed at aud.
Execution order
Mappers within a scope run in the order they were created. Default scopes run before the optional scopes the request named.
Claim collisions
If two mappers write the same claim name, the later one overwrites the earlier one, unless both values are JSON objects: objects merge key by key. That is how features.new_dashboard and features.beta_export end up in one features object, and how the two organization mappers share the organizations claim. Be intentional about claim names across scopes.
Token generation pipeline
- Resolve scopes. The client’s default scopes, plus any optional scopes named in the request’s
scopeparameter. - Gather mappers. Every protocol mapper on the active scopes.
- Execute mappers. Each one runs against the authenticated user and the client context, once for the access token and once for the ID token. The ID-token pass only happens when
scopeincludesopenid. - Filter by token type.
access.token.claimandid.token.claimdecide which token each claim lands in. - Strip reserved claims. The ten names above are removed.
- Build and sign. Mapper output is merged with the standard claims and the JWT is signed with the realm’s signing key.
Adding a mapper
Create or select a scope
Open Client Scopes in the admin console. Create a scope, or select the one that should carry the claim.
Add a protocol mapper
On the scope’s Protocol Mappers tab, choose Add mapper. Start from a quick-start template, or pick a mapper_type and fill the config keys documented above.
Assign the scope to a client
On the client’s Client Scopes tab, add the scope as Default or Optional. Default scopes apply to every request; an optional scope only applies when the request asks for it by name.
Check the result
Use Evaluate on the client’s Client Scopes tab, or call POST /realms/{realm_name}/clients/{client_id}/evaluate-scopes with a user_id and an optional scope. It returns the claims that user would receive without a full login. If a claim is missing, check the mapper_type and the config keys before anything else.