Skip to main content

Keycloak Protocol Mappers: A Complete Field Guide

A protocol mapper is the only thing that puts a claim in a Keycloak token. Storing an attribute on a user does not emit it; adding a user to a group does not emit it; granting a role emits it only because a mapper in the built-in roles client scope is already doing the work. Keycloak 26.8.1 ships 24 OIDC protocol mappers and 14 SAML ones, each mapper lives either on one client or on a shared client scope, and each carries a fixed priority that decides which mapper wins when two write the same claim.

This page is the list the official documentation does not print. Its OIDC token and SAML assertion mappings chapter says "the function of a mapper should be self-explanatory from its name", and its Priority order section explains that priorities exist without publishing a single value. Everything below was read out of a running server and tested against it.

Tested against

Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1 start-dev), on 2026-10-19. Every mapper id, priority, default switch, byte count and log line below was read from that server, not from documentation. The mapper inventory comes from GET /admin/serverinfo, so you can re-run it against your own version and diff the result.

Prerequisites​

docker run -d --name kc -p 127.0.0.1:8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.8.1 start-dev

Then a realm with something to map — a user with a custom attribute, a group, a realm role and a client role:

K="docker exec kc /opt/keycloak/bin/kcadm.sh"
$K config credentials --server http://localhost:8080 \
--realm master --user admin --password admin

$K create realms -s realm=mappers -s enabled=true
$K create clients -r mappers -s clientId=field-guide -s publicClient=false \
-s secret=secret -s directAccessGrantsEnabled=true
$K create clients -r mappers -s clientId=billing -s publicClient=false -s secret=secret

Declare the custom attributes in the realm's user profile before creating the user. On Keycloak 26 an attribute the user profile does not know about is discarded on write, with a 2xx and no warning — see managed vs unmanaged attributes for the whole trap.

docker exec -i kc tee /tmp/profile.json >/dev/null <<'JSON'
{"attributes":[
{"name":"username","permissions":{"view":["admin","user"],"edit":["admin","user"]}},
{"name":"email","permissions":{"view":["admin","user"],"edit":["admin","user"]}},
{"name":"firstName","permissions":{"view":["admin","user"],"edit":["admin","user"]}},
{"name":"lastName","permissions":{"view":["admin","user"],"edit":["admin","user"]}},
{"name":"department","permissions":{"view":["admin","user"],"edit":["admin"]}},
{"name":"team","multivalued":true,"permissions":{"view":["admin","user"],"edit":["admin"]}}
],"groups":[]}
JSON
$K update users/profile -r mappers -f /tmp/profile.json

$K create users -r mappers -s username=ada -s enabled=true \
-s email=ada@example.com -s firstName=Ada -s lastName=Lovelace \
-s emailVerified=true \
-s 'attributes.department=["platform"]' -s 'attributes.team=["sre","identity"]'
$K set-password -r mappers --username ada --new-password pass

Get a token at any point with:

curl -s -d client_id=field-guide -d client_secret=secret \
-d username=ada -d password=pass -d grant_type=password -d scope=openid \
http://127.0.0.1:8080/realms/mappers/protocol/openid-connect/token \
| jq -r .access_token | cut -d. -f2 | base64 -d 2>/dev/null | jq .

Every OIDC protocol mapper in Keycloak 26.8.1​

Sorted by priority — the order in which they actually run. id, access, userinfo, intro and lw are the five destination switches and the column shows each mapper's default; — means the mapper does not offer that switch at all.

Mapper idAdmin console namePrioWhat it emitsid / access / userinfo / intro / lw
oidc-sub-mapperSubject (sub)-10sub — the user id. Runs first so anything else can overwrite it— / on / — / on / off
oidc-acr-mapperAuthentication Context Class Reference (ACR)0acr, the achieved level of authenticationon / on / — / on / off
oidc-address-mapperUser Address0the OIDC address object from six separate user attributeson / on / on / on / off
oidc-allowed-origins-mapperAllowed Web Origins0allowed-origins from the client's Web Origins— / on / — / on / off
oidc-amr-mapperAuthentication Method Reference (AMR)0amr — how the user actually authenticatedon / on / — / — / off
oidc-audience-mapperAudience0adds one named client, or a literal string, to audoff / on / — / on / off
oidc-claims-param-token-mapperClaims parameter Token0honours the OIDC claims request parameteron / — / on / — / —
oidc-claims-param-value-idtoken-mapperClaims parameter with value ID Token0the claims parameter's value form, ID token onlyon / — / — / — / —
oidc-full-name-mapperUser's full name0name, as first + space + laston / on / on / on / off
oidc-group-membership-mapperGroup Membership0group names, full path or leaf nameon / on / on / on / off
oidc-hardcoded-claim-mapperHardcoded claim0a constant. The one mapper whose value never depends on the useron / on / on / on / off
oidc-nonce-backwards-compatible-mapperNonce backwards compatible0nonce on access and refresh tokens, for pre-24 clients— / — / — / — / —
oidc-organization-membership-mapperOrganization Membership0organization, optionally with id, domain and attributeson / on / on / on / off
oidc-session-state-mapperSession State (session_state)0session_state, the legacy sibling of sidon / on / on / on / off
oidc-sha256-pairwise-sub-mapperPairwise subject identifier0a salted SHA-256 sub, different per sector identifier— / — / — / — / —
oidc-usermodel-attribute-mapperUser Attribute0the workhorse. Any user-profile attribute, to any claimon / on / on / on / off
oidc-usermodel-property-mapperUser Property0only the built-in fields — email, firstName, lastName, emailVerified, idon / on / on / on / off
oidc-usersessionmodel-note-mapperUser Session Note0a session note: auth_time, impersonator id, service-account client hoston / on / on / on / off
oidc-organization-group-membership-mapperOrganization Group Membership10organization-scoped group membershipon / on / on / on / off
oidc-role-name-mapperRole Name Mapper10renames one role in the token. Does not add it— / — / — / — / —
oidc-hardcoded-role-mapperHardcoded Role20grants a role in the token that the user does not hold— / — / — / — / —
oidc-audience-resolve-mapperAudience Resolve30every client the user holds a client role on, into aud— / on / — / on / off
oidc-usermodel-client-role-mapperUser Client Role40client roles, by default into resource_access.*.roleson / on / on / on / off
oidc-usermodel-realm-role-mapperUser Realm Role40realm roles, by default into realm_access.roleson / on / on / on / off

Three things to take from the shape of that table.

The priorities are not decorative. Role mappers run at 40 so that Audience Resolve at 30 has already computed audiences, and sub runs at -10 so the pairwise mapper at 0 can overwrite it — which is why the docs note you do not have to delete the built-in sub mapper to use pairwise identifiers.

Five mappers have no destination switches at all. oidc-role-name-mapper, oidc-hardcoded-role-mapper and oidc-sha256-pairwise-sub-mapper modify something another mapper will later emit; they are transforms, not emitters. Looking for the "Add to ID token" toggle on one of them is a dead end.

User Property is not a cheaper User Attribute. It reads the five built-in columns on the user record only. For anything you declared in the user profile, you want oidc-usermodel-attribute-mapper.

The Script Mapper is not in this list

The documentation describes a Script Mapper. It does not appear among the 24, and enabling the scripts preview feature does not add it — we checked, the count stays at 24. A script mapper type is registered only once you deploy a script provider JAR to /opt/keycloak/providers/. Until then there is nothing to select.

For SAML clients the equivalent set is 14 mappers — saml-user-attribute-mapper, saml-role-list-mapper, saml-group-membership-mapper, saml-audience-mapper and so on. All sit at priority 0 except saml-organization-group-membership-mapper at 10, so collisions between SAML mappers are resolved by nothing you can configure. Read your own server's list with:

$K get serverinfo --fields 'protocolMapperTypes(saml(id,name,priority))'

Where should the mapper live?​

This is the decision the docs do not frame, and the one that causes duplicate claims. There are three places, and they differ in who gets the claim and when:

Put it onReachesFiresUse when
The client's dedicated scope (<client>-dedicated)that one clientalwaysthe claim is meaningful only to this application
A shared client scope, attached as defaultevery client the scope is attached toalwaysthree services need the same claim and you do not want three copies
A shared client scope, attached as optionalthose clientsonly when the client asks for it by name in the scope parameterthe claim is sensitive, large, or wanted by one caller in ten

The optional case is worth measuring, because "I added the mapper and the claim isn't there" is nearly always this:

SC=$($K create client-scopes -r mappers -s name=finance -s protocol=openid-connect \
-s 'attributes."include.in.token.scope"=true' -i)
$K create client-scopes/$SC/protocol-mappers/models -r mappers \
-s name=cost-center -s protocol=openid-connect \
-s protocolMapper=oidc-hardcoded-claim-mapper \
-s 'config."claim.name"=cost_center' -s 'config."claim.value"=CC-4471' \
-s 'config."jsonType.label"=String' -s 'config."access.token.claim"=true'
FG=$($K get clients -r mappers -q clientId=field-guide --fields id --format csv --noquotes)
$K update clients/$FG/optional-client-scopes/$SC -r mappers
scope=openid scope claim: "openid email profile" cost_center absent
scope=openid finance scope claim: "openid finance email profile" cost_center "CC-4471"

In the admin console the same three places are Clients → client → Client scopes → <client>-dedicated → Add mapper, and Client scopes → scope → Mappers for the shared ones, with the Default/Optional choice made on the client's Client scopes tab.

Check what you already emit before adding anything​

A brand-new OIDC client is not empty. On a stock realm it inherits six default client scopes carrying 23 mappers that always run, plus five optional scopes carrying six more. Nothing in the console tells you that before you add the 24th. List yours first:

for s in profile email roles basic web-origins acr \
phone address microprofile-jwt organization offline_access; do
echo "== $s"
ID=$($K get client-scopes -r mappers --fields id,name --format csv --noquotes \
| grep ",$s$" | cut -d, -f1)
$K get client-scopes/$ID/protocol-mappers/models -r mappers \
--fields name,protocolMapper --format csv --noquotes
done

profile is 14 of the 23 on its own — preferred_username, name, given_name, family_name, locale and nine more. roles holds three: realm_access.roles, resource_access.${client_id}.roles and Audience Resolve. basic holds sub and auth_time. offline_access holds none. A second mapper writing preferred_username does not error — it produces a race you will read about in the next section.

The five destination switches​

Every emitting mapper has up to five booleans, and they are independent. Measured on one oidc-usermodel-attribute-mapper for department:

SwitchConfig keyEffect when on
Add to ID tokenid.token.claimclaim appears in id_token
Add to access tokenaccess.token.claimclaim appears in the access token
Add to userinfouserinfo.token.claimclaim returned by /protocol/openid-connect/userinfo
Add to token introspectionintrospection.token.claimclaim returned by /token/introspect
Add to lightweight access tokenlightweight.claimclaim survives when the client issues lightweight tokens (default off)

Two measured surprises.

Introspection is not gated the way you expect on a normal token. With introspection.token.claim left unset, team, groups_full and the rest still came back from /token/introspect, because introspection of a regular access token reflects the token's own contents. The switch earns its keep only for lightweight tokens, where the point is a claim that is absent from the token but available on introspection.

UserInfo is the strictest surface. A claim with only access.token.claim=true is absent from /userinfo even though the bearer token presented to it carries the claim.

Priority order, measured​

The docs state that mappers run lowest priority first and that priority belongs to the implementation, not the configuration. What follows from that — and is not stated anywhere — is that when two mappers write the same claim, the higher priority number wins, because it runs later and overwrites.

Two mappers on claim prio, a hardcoded claim (priority 0) and a realm role mapper (priority 40):

"prio": ["offline_access","analyst","uma_authorization","default-roles-mappers"]

The role mapper won on all three runs. The hardcoded value never appeared.

Now the case that matters more, because it is the one people create by accident — two mappers at the same priority. A hardcoded claim and a user attribute mapper, both priority 0, both writing collision:

run 1 "collision": "platform" <- the attribute mapper won
run 2 "collision": "platform"
run 3 "collision": "platform"

Stable, so far so good. Then we deleted both and recreated them with the names swapped, so the hardcoded mapper now sorts last alphabetically and was created last:

run 1-5 "collision": "platform" <- the attribute mapper won again

Neither the mapper name nor the creation order changed the outcome. There is no setting that does. The practical rule is not "learn the tiebreak" — it is never create one. Two mappers on one claim name is a bug whichever one happens to win today.

Getting the value right: jsonType.label and multivalued​

jsonType.label decides the JSON type of the emitted claim. Six hardcoded mappers, all with claim.value set, differing only in the type label:

jsonType.labelclaim.valueEmitted
String42"42"
(unset)42"42" — string is the default
int4242
long4242
booleantruetrue
JSON{"tier":"gold","seats":5}{"tier": "gold", "seats": 5} — parsed, not escaped
intnot-a-numberclaim absent. No error, no log line.

That last row is the sharp edge. A cast that fails drops the claim silently, and nothing in the server log records it — we grepped. If a numeric claim is intermittently missing, suspect a value that is not numeric for some users rather than the mapper.

multivalued is the matching trap on the attribute side. The user above has team = ["sre","identity"]:

multivalued unset -> "team_wrong": "sre" first value only
multivalued true -> "team": ["sre","identity"]

This one does log, and the line is worth knowing by sight:

WARN [org.keycloak.services] KC-SERVICES0046: Multiple values found '[sre, identity]'
for protocol mapper 'team-wrong' but expected just single value

Dotted claim names nest​

A claim.name containing a dot produces a nested object. To get a literal dot in the key, escape it with a backslash:

claim.nameEmitted
acme.org.dept{"acme": {"org": {"dept": "platform"}}}
acme\.flat{"acme.flat": "platform"}

Namespaced claims like https://acme.example/dept therefore need escaping on every dot, or Keycloak builds you a four-level object.

What mappers cost: measured token sizes​

Access token size is the reason to care about which mappers are on. Same realm, same user, scope=openid, mappers added one at a time:

ConfigurationAccess tokenClaims
Stock confidential client, default scopes only1,293 bytes19
+ User Attribute (department)1,325 bytes20
+ Group Membership, full path1,371 bytes21
+ Audience (the client itself)1,382 bytes21
Same client, lightweight access token751 bytes8

Groups and roles are what actually grow a token — a user in forty groups pays for all forty, every request. If you are heading for the 8 KB header limit, the lever is a lightweight access token, not trimming attributes.

Lightweight access tokens strip more than PII​

Apply the use-lightweight-access-token client policy executor and the token drops to eight claims. What the docs list as unremovable — exp, iat, jti, iss, typ, azp, sid, scope, cnf — is exactly what is left. sub and aud are both gone, which breaks most resource servers and breaks introspection. Measured:

lightweight, nothing opted in 751 bytes, 8 claims (no sub, no aud)
+ lightweight.claim on sub, aud,
and the department mapper 882 bytes, 11 claims (sub, aud, department back)

So the migration to lightweight tokens is not one switch. It is one switch plus an audit of every mapper whose claim your services read, setting lightweight.claim=true on each — including the built-in sub mapper, which lives on the basic client scope, not on your client.

Verify it worked, without logging anyone in​

Keycloak will render the exact token a given user would receive, from the admin API. This is the fastest mapper debugging tool in the product and almost nobody uses it:

U=$($K get users -r mappers -q username=ada --fields id --format csv --noquotes)
FG=$($K get clients -r mappers -q clientId=field-guide --fields id --format csv --noquotes)
$K get clients/$FG/evaluate-scopes/generate-example-access-token \
-r mappers -q userId=$U -q scope=openid

It applies the full mapper chain, priorities included, and returns the token body. In the console it is Clients → client → Client scopes → Evaluate. Change -q scope=... to see what an optional scope adds.

Troubleshooting​

The claim is missing and the mapper looks right. In order: is the mapper on a client scope attached as optional and the client never asks for it; is the user profile actually storing the attribute (check it); is jsonType.label set to int against a non-numeric value; is the destination switch for the surface you are reading set.

Only the first of several values arrives. multivalued is off. Look for KC-SERVICES0046 in the server log.

Introspection returns {"active": false} on a token you just issued. The client calling /token/introspect is not in the token's aud. The server logs INTROSPECT_TOKEN_ERROR ... reason="Client 'x' is not in the token audience", the HTTP status is 200, and the response is indistinguishable from an expired token. Add an oidc-audience-mapper naming the client itself. The same audience rule is why a valid token gets a 401 from a resource server — the aud trap covers it end to end.

A client appears in aud that you never configured. oidc-audience-resolve-mapper, in the default roles scope, adds every client the user holds a client role on. In our realm billing was in aud before any audience mapper existed. Adding an explicit Audience mapper for a client that already resolves is a no-op.

A claim changed value after you added an unrelated mapper. Two mappers are writing it. Search every scope attached to the client, not just the dedicated one — the built-in scopes are where the other one usually is.

The server warns about Full scope allowed. Unrelated to mappers but it fires on the same request: the client switch is deprecated, and leaving it on means role mappers emit every role the user has rather than the ones the client needs. See roles, composite roles, and groups.

Next steps​