# 14 — The authentication converter: from claims to authorities [← the validator stack](13-validator-stack.md) · [next: JWKS caching and key rotation →](15-jwks-caching-and-rotation.md) Validation decides whether a token is genuine. Conversion decides what it is allowed to do. They fail differently: a validation failure is a **401** with a reason in `WWW-Authenticate`; a conversion failure is a **403** with `insufficient_scope` and no explanation of what was missing. Three transcripts of the same token against the same endpoints, differing only in the converter: | profile | authorities produced | `/api/reports` | `/api/admin/stats` | |---|---|---|---| | [`stub`](output/rs-converter-default.txt) | `SCOPE_profile:read`, `SCOPE_reports:read` | 403 | 403 | | [`stub,roles`](output/rs-converter-java.txt) | the above plus `ROLE_USER`, `ROLE_reports-reader` | 200 | 403 | | [`stub,propsroles`](output/rs-converter-properties.txt) | `ROLE_USER`, `ROLE_reports-reader` | 200 | 403 | `FACTOR_BEARER` appears in all three; it is a Spring Security 7 addition covered in [chapter 11](11-spring-security-7-changes.md). ## What the default converter looks at `JwtGrantedAuthoritiesConverter` reads the `scope` claim, or `scp` if `scope` is absent, splits it on whitespace, and prefixes each value with `SCOPE_`. That is the whole algorithm. Keycloak emits `scope`, so scopes work with no configuration. Roles do not, because Keycloak puts them here: ```json "realm_access": { "roles": ["USER"] }, "resource_access": { "reports-api": { "roles": ["reports-reader"] } } ``` Neither is the `scope` claim, so the default converter finds nothing, and every `hasRole(..)` rule returns 403 against a token that authenticated perfectly. The first transcript in the table above is that failure, and `nestedKeycloakRolesAreInvisibleToTheDefaultAuthoritiesConverter` asserts it. ## Route one: configuration only Spring Boot 4 added `authorities-claim-expressions`, a list of SpEL expressions evaluated against the claim map. Nested claims need no Java: ```yaml spring: security: oauth2: resourceserver: jwt: principal-claim-name: preferred_username authority-prefix: "ROLE_" authorities-claim-expressions: - "[realm_access][roles]" - "[resource_access]['reports-api'][roles]" ``` **Quote the hyphenated client id.** Inside a SpEL indexer the contents are an expression, not a literal key, so `[resource_access][reports-api][roles]` parses as `reports` minus `api`: ``` [realm_access][roles] -> [USER] [resource_access][reports-api][roles] -> SpelEvaluationException: EL1008E: Property or field 'reports' cannot be found [resource_access]['reports-api'][roles] -> [reports-reader] ``` And the failure does not reach you. `ExpressionJwtGrantedAuthoritiesConverter.getAuthorities` catches `ExpressionException`, substitutes an empty list, and logs the reason at **TRACE**: ```java catch (ExpressionException ee) { if (this.logger.isTraceEnabled()) { this.logger.trace(LogMessage.format("Failed to evaluate expression. error=%s", ee.getMessage())); } authorities = Collections.emptyList(); } ``` So a mistyped expression produces a 403, no exception, no WARN, and nothing in the log at default levels. If a claim expression is not producing the authority you expect, the first move is: ```yaml logging.level.org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter: TRACE ``` Two more limits of this route, both visible in the table above: - **`authority-prefix` is a single value applied to every expression.** A mixed mapping — `SCOPE_` for scopes and `ROLE_` for roles — cannot be expressed here. - **Naming expressions replaces the default converter.** The `SCOPE_*` authorities are gone from the `propsroles` row for exactly that reason. Adding `[scope]` as an expression brings the values back, prefixed `ROLE_`, which is not what you meant. `authorities-claim-expressions`, `authorities-claim-name` and `authorities-claim-delimiter` are mutually exclusive; combining them throws `MutuallyExclusiveConfigurationPropertiesException` at startup, which is the one failure in this chapter that is loud. ## Route two: a `JwtAuthenticationConverter` bean When the mapping is mixed, write it: ```java @Bean JwtAuthenticationConverter keycloakJwtAuthenticationConverter() { JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setPrincipalClaimName("preferred_username"); converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter("reports-api")); return converter; } ``` Full source: [`KeycloakAuthoritiesConfig`](../oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/KeycloakAuthoritiesConfig.java). It delegates scopes to the stock `JwtGrantedAuthoritiesConverter` and adds realm and client roles with a `ROLE_` prefix, which is the mapping the middle row of the table produces. **Defining this bean silently disables every one of the properties above.** Boot's `JwtConverterConfiguration` is annotated `@ConditionalOnMissingBean(JwtAuthenticationConverter.class)`, so the moment your bean exists, `principal-claim-name`, `authority-prefix`, `authorities-claim-name`, `authorities-claim-delimiter` and `authorities-claim-expressions` stop doing anything. No warning is logged. If you have both, the YAML is decoration. ## Flattening namespaces has a cost The converter here maps realm roles and client roles into one `ROLE_` namespace. That reads well and matches what `hasRole(..)` expects, but if two clients in your realm each define a role named `admin`, both collapse onto `ROLE_admin` and a token for one client passes a check meant for the other. Prefix by client id if that is a real risk in your realm. ## Choosing - Only scopes matter, and the issuer emits `scope` → change nothing. - Roles from one nested claim, one prefix → properties. - Two prefixes, filtering, a custom principal type, or anything conditional → a bean, and delete the properties so nobody reads them and believes them. --- [← the validator stack](13-validator-stack.md) · [next: JWKS caching and key rotation →](15-jwks-caching-and-rotation.md)