- Published on
Customizing Keycloak Tokens — Protocol Mappers and Claims Design in Practice
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- How Client Scopes and Protocol Mappers Relate
- A Tour of the Built-in Mappers
- Implementing a Custom ProtocolMapper SPI
- The Audience Trap — When aud Validation Fails
- Token Bloat and Diet Strategies
- Distributing Claims — ID Token vs Access Token vs UserInfo
- Keeping Claims Consistent Across Many Clients
- The Danger of Script Mappers
- Testing — kcadm and Token Introspection
- An Anti-pattern Checklist for Claims Design
- Closing Thoughts
- References
Introduction
The first practical challenge a team faces after adopting Keycloak is usually not authentication itself, but deciding what goes inside the token. An access token issued with default settings contains neither the department code nor the internal permission grade your services need. Conversely, after a few years of operation you may hit the opposite problem: tokens swelling past 8KB and threatening HTTP header limits.
As of 2026, Keycloak 26.6 has diversified token issuance paths with the JWT Authorization Grant, federated client authentication, and final support for the FAPI 2.0 Security Profile. Thanks to the experimental OAuth Client ID Metadata Document (CIMD) feature, Keycloak can even act as an authorization server for MCP (Model Context Protocol) based AI agents. With token consumers expanding beyond human browser sessions to AI agents, batch workloads, and external partner systems, claims design is no longer a side concern — it is the core of your security architecture.
This article walks through everything in practical order: how Protocol Mappers and Client Scopes work, implementing a custom SPI, the audience trap, token diet strategies, and validation automation.
How Client Scopes and Protocol Mappers Relate
Every claim that ends up in a Keycloak token is produced by a Protocol Mapper. A mapper can be attached in two places.
- Mappers attached to a Client Scope — a reusable unit shared across multiple clients
- Dedicated mappers attached directly to a client — specific to that single client
The evaluation order at token issuance time looks like this.
+--------------------------------------------------------------+
| Token Issuance Pipeline |
+--------------------------------------------------------------+
Authorization Request (scope=openid profile email teams)
|
v
+--------------+ +---------------------------+
| Default | | Optional Client Scopes |
| Client Scopes| <-- | (only when requested via |
+--------------+ | the scope parameter) |
| +---------------------------+
+----------+----------+
v
+---------------------+
| Effective Scope Set |
+---------------------+
|
v
+---------------------+ +--------------------+
| Protocol Mappers | <--- | Dedicated Mappers |
| (linked to scopes) | | (client-specific) |
+---------------------+ +--------------------+
|
v
+-----------------------------------------+
| ID Token / Access Token / UserInfo |
+-----------------------------------------+
There are three key rules.
- Default scopes are always evaluated even if the client does not request them. Typical examples are
profile,email,roles, andweb-origins. - Optional scopes are evaluated only when explicitly listed in the scope parameter of the authorization request. Claims that only some clients need — department info, for instance — should be split into optional scopes; this is the starting point of any token diet.
- Each mapper has separate Add to ID token / Add to access token / Add to userinfo toggles, so the same claim can be included or excluded per token type.
The standard operational pattern is to create realm-wide scopes in the Client Scopes menu of the admin console, then link them as default or optional in the Client Scopes tab of each client.
A Tour of the Built-in Mappers
Among the mappers Keycloak ships out of the box, these are the ones used most frequently in practice.
| Mapper type | Purpose | Key settings |
|---|---|---|
| User Attribute | Expose a user attribute as a claim | attribute name, claim name, JSON type |
| User Property | Built-in fields such as username and email | property name, claim name |
| Group Membership | List of group memberships as a claim | full path toggle |
| Role Name Mapper | Rename a role inside the token | original role, new name |
| User Realm Role | Realm role list as a claim | claim name, multivalued |
| User Client Role | Extract roles of one specific client | client id, claim name |
| Audience | Add a target client to the aud claim | included client audience |
| Audience Resolve | Compute aud automatically from roles | none |
| Hardcoded Claim | Insert a fixed-value claim | claim name, value, type |
| Pairwise subject identifier | Per-client anonymized sub | sector identifier URI |
| Allowed Web Origins | Inject permitted CORS origins | none |
Let us create a User Attribute mapper with the kcadm CLI. This example exposes the user attribute department as the dept claim in the access token.
# Create the client scope
kcadm.sh create client-scopes -r myrealm \
-s name=org-info \
-s protocol=openid-connect \
-s 'attributes."include.in.token.scope"=true'
# Add a user attribute mapper to the scope
kcadm.sh create client-scopes/SCOPE_ID/protocol-mappers/models -r myrealm \
-s name=dept-mapper \
-s protocol=openid-connect \
-s protocolMapper=oidc-usermodel-attribute-mapper \
-s 'config."user.attribute"=department' \
-s 'config."claim.name"=dept' \
-s 'config."jsonType.label"=String' \
-s 'config."access.token.claim"=true' \
-s 'config."id.token.claim"=false' \
-s 'config."userinfo.token.claim"=true'
# Link it to the client as an optional scope
kcadm.sh update clients/CLIENT_UUID/optional-client-scopes/SCOPE_ID -r myrealm
One caveat with the Group Membership mapper is the Full group path option. When enabled, the claim contains the full path such as /engineering/platform/sre; when disabled, only the leaf group name like sre. If consuming applications parse the path, flipping this option breaks every permission check downstream — agree on a team standard from day one.
The Hardcoded Claim mapper is handy for environment identification. Stamping every token from the staging realm with an env claim set to staging lets resource servers block cross-environment token misuse with a single line of validation.
Implementing a Custom ProtocolMapper SPI
For requirements the built-in mappers cannot satisfy — say, embedding the result of an external HR system lookup, or combining multiple attributes into one structured claim — you implement the ProtocolMapper SPI.
Declare the Maven dependencies with provided scope.
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>26.6.2</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>26.6.2</version>
<scope>provided</scope>
</dependency>
</dependencies>
The implementation class extends AbstractOIDCProtocolMapper and implements three marker interfaces.
package com.example.keycloak.mapper;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class EmployeeGradeMapper extends AbstractOIDCProtocolMapper
implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper {
public static final String PROVIDER_ID = "employee-grade-mapper";
private static final List<ProviderConfigProperty> CONFIG = new ArrayList<>();
static {
// Automatically adds the three token-inclusion toggles to the admin console
OIDCAttributeMapperHelper.addTokenClaimNameConfig(CONFIG);
OIDCAttributeMapperHelper.addIncludeInTokensConfig(CONFIG, EmployeeGradeMapper.class);
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "Employee Grade Mapper";
}
@Override
public String getDisplayCategory() {
return TOKEN_MAPPER_CATEGORY;
}
@Override
public String getHelpText() {
return "Combines job-code and grade attributes into one structured claim.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return CONFIG;
}
@Override
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel,
UserSessionModel userSession, KeycloakSession session,
ClientSessionContext clientSessionCtx) {
String jobCode = userSession.getUser().getFirstAttribute("jobCode");
String grade = userSession.getUser().getFirstAttribute("grade");
if (jobCode == null || grade == null) {
return; // If the value is missing, omit the claim entirely
}
Map<String, Object> value = Map.of(
"jobCode", jobCode,
"grade", Integer.parseInt(grade));
OIDCAttributeMapperHelper.mapClaim(token, mappingModel, value);
}
}
Do not forget the service registration file, or the mapper will never appear in the console.
src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper
--------------------------------------------------------------------------
com.example.keycloak.mapper.EmployeeGradeMapper
Drop the built JAR into the providers directory and rerun the build phase.
cp target/employee-grade-mapper.jar /opt/keycloak/providers/
/opt/keycloak/bin/kc.sh build
/opt/keycloak/bin/kc.sh start --optimized
Two operational points deserve emphasis. First, avoid synchronous external API calls inside setClaim. Token issuance sits on the login critical path, so any external latency becomes company-wide login latency. The safe pattern is to sync external data into user attributes ahead of time and let the mapper only read. Second, SPI signatures can change across major Keycloak upgrades, so custom mappers must be on your upgrade checklist.
The Audience Trap — When aud Validation Fails
The single most frequent outage source in token customization is the aud claim. The classic incident scenario goes like this.
- A frontend SPA logs in via the
web-appclient and receives an access token. - It calls the backend
order-apiwith that token. order-apiis configured as a Spring Security resource server with audience validation enabled.- The token contains only
accountinaud, so the call fails with 401 invalid_token.
By default Keycloak does not know which resource server will consume a token, so it does not populate aud for you. The fix is to add an Audience mapper explicitly.
# Client scope + audience mapper that adds order-api to aud
kcadm.sh create client-scopes -r myrealm \
-s name=order-api-audience -s protocol=openid-connect
kcadm.sh create client-scopes/SCOPE_ID/protocol-mappers/models -r myrealm \
-s name=order-api-aud \
-s protocol=openid-connect \
-s protocolMapper=oidc-audience-mapper \
-s 'config."included.client.audience"=order-api' \
-s 'config."access.token.claim"=true' \
-s 'config."id.token.claim"=false'
The validating side (Spring) is covered in detail in the next article, but here is the essence.
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
"aud", aud -> aud != null && aud.contains("order-api"));
The most common traps, condensed:
- Misunderstanding the Audience Resolve mapper: it computes aud based on the client roles present in the token. If the user holds no client role of the target resource server, nothing is added to aud.
- Adding aud to the ID token and calling it done: the aud of an ID token is always the requesting client itself. Resource servers validate the access token, so verify that the access token toggle is enabled on the mapper.
- Confusing azp with aud:
azpis the client that requested the token (authorized party), whileaudis the intended consumer. A gateway that admits requests based only on azp is vulnerable to the token-reuse attacks that the RFC 9700 OAuth Security BCP warns about. - For stricter isolation, consider RFC 8707 resource indicators or token exchange (RFC 8693) to mint per-resource-server tokens.
Token Bloat and Diet Strategies
A common symptom in a Keycloak deployment two or three years into operation is access tokens ballooning to several kilobytes. The usual causes:
- Hundreds of groups all included via the Group Membership mapper
- Realm roles plus every client role dumped wholesale into
realm_accessandresource_access - Every claim bound to default scopes and therefore injected into every client token
A bigger token means every HTTP request header grows with it, and once you cross 8KB you start hitting the NGINX default large_client_header_buffers or the 16KB header limit of AWS ALB, producing sporadic 502 errors. Diet strategies, in priority order:
- Split scopes: keep only frequently used claims in default scopes and move the rest to optional scopes, requested via the scope parameter only when needed.
- Filter roles: turn off Full Scope Allowed in the client Scope tab and assign only the roles that client actually needs. This alone often shrinks
resource_accessdramatically. - Derived claims instead of groups: rather than the full group list, compute a summary value such as a permission grade with a custom mapper.
- Move to userinfo: profile data used only for display should leave the access token and be fetched from the userinfo endpoint.
- Lightweight access tokens: the lightweight access token feature introduced in Keycloak 24 strips many default claims from the access token; resource servers then use token introspection for details.
Distributing Claims — ID Token vs Access Token vs UserInfo
Design becomes simple once the roles of the three delivery channels are clearly separated.
| Channel | Consumer | What belongs there | What does not |
|---|---|---|---|
| ID Token | Client app | Proof of authentication, minimal display profile | Authorization data for APIs |
| Access Token | Resource server | aud, roles, claims used for authorization decisions | Detailed display profile |
| UserInfo | Client app | Detailed profile, frequently changing data | The basis of authorization decisions |
The principle is simple: the ID token is evidence of authentication, the access token is input for authorization, and userinfo is the source of profile data. If you put permissions in the ID token and let the client authorize itself, you simultaneously inherit two problems: permission changes are not reflected for the token lifetime, and the client can be tampered with.
Another practically important matter is where personally identifiable information lives. Access tokens travel through every microservice and logging pipeline, so sensitive data such as national ID fragments must never be in the access token — isolate it behind userinfo or a dedicated API.
Keeping Claims Consistent Across Many Clients
Once you have dozens of clients, entropy creeps in: the same semantic claim ends up with a different name per client — dept, department, and org_code coexisting. Preventive measures:
- Shared client scopes as the single source of truth: define claims only in realm-level client scopes, and adopt a team rule banning dedicated mappers.
- Document a claim naming convention: maintain a claim dictionary recording name, type, source attribute, and target tokens.
- Codify with Terraform or kcadm: managing mappers declaratively through the Keycloak Terraform provider or kcadm scripts prevents drift between environments, unlike manual console edits.
# Script that dumps mapper state for all clients to audit drift
for c in $(kcadm.sh get clients -r myrealm --fields id --format csv --noquotes); do
kcadm.sh get clients/$c/protocol-mappers/models -r myrealm \
--fields name,protocolMapper,config
done > mappers-audit.json
The Danger of Script Mappers
Keycloak used to offer a Script Mapper that computed claims in JavaScript, but today it is disabled by default and classified as a preview feature. Enabling it requires an explicit deployment flag.
kc.sh start --features=scripts
The reasons to avoid script mappers are clear.
- Security: admin console access becomes code execution on the server. If an admin account is compromised, the blast radius expands from realm misconfiguration to RCE.
- Portability: the dependency on Nashorn-lineage engines makes compatibility fragile across Keycloak upgrades.
- No observability: when a script error breaks token issuance, tracing the cause is painful.
If you still run script mappers, the recommended 2026 migration path is the custom ProtocolMapper SPI covered above — Java code that can be compiled, code-reviewed, and version-controlled.
Testing — kcadm and Token Introspection
Claims design must be paired with automated verification. The first step is the Evaluate feature in the admin console (Client Scopes tab), which previews the token for a given user and scope combination without issuing one. In CI, obtain a real token and inspect it.
# 1. Issue a test token via password grant (CI-only confidential client)
TOKEN=$(curl -s -X POST \
"https://kc.example.com/realms/myrealm/protocol/openid-connect/token" \
-d grant_type=password \
-d client_id=ci-test-client \
-d client_secret=$CLIENT_SECRET \
-d username=testuser \
-d password=$TEST_PASSWORD \
-d scope="openid org-info" | jq -r .access_token)
# 2. Decode the payload and assert on claims
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null \
| jq -e '.dept == "platform" and (.aud | index("order-api"))'
# 3. Check the server-side verdict via the introspection endpoint
curl -s -X POST \
"https://kc.example.com/realms/myrealm/protocol/openid-connect/token/introspect" \
-u order-api:$RS_SECRET \
-d token=$TOKEN | jq '{active, aud, scope, dept}'
Introspection matters especially when you adopt an opaque or lightweight token strategy. If the active field is false, the cause is expiry, revocation, or a signature mismatch; asserting on the introspection response in CI catches regressions where a mapper change breaks resource server validation before deployment.
Finally, a token size regression test is also recommended.
SIZE=$(echo -n $TOKEN | wc -c)
if [ "$SIZE" -gt 4096 ]; then
echo "FAIL: access token is $SIZE bytes (limit 4096)"; exit 1
fi
An Anti-pattern Checklist for Claims Design
Finally, an anti-pattern checklist you can use directly in design reviews.
- Placing every claim in default scopes — the number one cause of token bloat. Splitting into optional scopes is fundamental.
- Duplicating the same data in both the access token and userinfo — go back and re-agree on the role of each channel.
- Putting sensitive data in the access token — it is easy to forget that the access token traverses every service and logging pipeline.
- Adding the aud mapper but skipping resource server validation — the mapper configuration and the validation code always come as a pair.
- Overusing dedicated mappers — the starting point of the entropy where claim names diverge per client.
- Introducing new script mappers — first prove in writing why it cannot be implemented as an SPI.
- Handling mapper changes manually in the console — uncodified changes create cross-environment drift and make rollback impossible.
- No token size monitoring — a size regression test is what breaks the path from one added mapper to a gateway 502.
Closing Thoughts
A Protocol Mapper looks like a small setting, but the claims it produces determine the authorization decisions of your entire microservice fleet, your header budget, and your PII exposure surface. To summarize:
- Design claims at the client scope level, separating default from optional to prevent token bloat.
- aud is never populated automatically. Declare an Audience mapper and always validate it on the resource server.
- Distribute claims by separating the roles of the ID token, access token, and userinfo.
- Use the custom ProtocolMapper SPI instead of script mappers.
- Put claim verification into CI with Evaluate, kcadm, and introspection.
The next article covers the consuming side: how Spring Security 6 resource servers process the tokens designed here.
References
- Keycloak Server Administration Guide — Protocol Mappers
- Keycloak Server Developer Guide — Service Provider Interfaces
- Keycloak Release Notes
- Keycloak 26.6.0 Released
- OpenID Connect Core 1.0
- RFC 7519 — JSON Web Token (JWT)
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- RFC 8693 — OAuth 2.0 Token Exchange
- RFC 7662 — OAuth 2.0 Token Introspection
- RFC 8707 — Resource Indicators for OAuth 2.0
- OAuth 2.1 draft