1
0
Files
jwt-auth-demo/docs/14-authentication-converter.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
2026-08-23 11:00:56 +00:00

6.2 KiB

14 — The authentication converter: from claims to authorities

← the validator stack · next: JWKS caching and key rotation →

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 SCOPE_profile:read, SCOPE_reports:read 403 403
stub,roles the above plus ROLE_USER, ROLE_reports-reader 200 403
stub,propsroles ROLE_USER, ROLE_reports-reader 200 403

FACTOR_BEARER appears in all three; it is a Spring Security 7 addition covered in chapter 11.

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:

"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:

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:

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:

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:

@Bean
JwtAuthenticationConverter keycloakJwtAuthenticationConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setPrincipalClaimName("preferred_username");
    converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter("reports-api"));
    return converter;
}

Full source: KeycloakAuthoritiesConfig. 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 · next: JWKS caching and key rotation →