skip to content

How does a JWT's scopes/claims become Spring Security GrantedAuthorities, and how do you customize that mapping with JwtAuthenticationConverter?

level: seniorimportance: must knowfreq 66%

answer

  1. scope/scp -> SCOPE_ prefix
  2. setAuthorityPrefix + setAuthoritiesClaimName
  3. Keycloak roles nested in realm_access.roles
  4. jwtAuthenticationConverter(...) to register
  5. hasRole=ROLE_ vs default SCOPE_ mismatch

basics

~20 s

By default Spring reads the scope (or scp) claim and turns each value into a SCOPE_<value> authority. To use different claims — like Keycloak realm roles — supply a JwtAuthenticationConverter with a custom JwtGrantedAuthoritiesConverter (prefix + claim name) via oauth2ResourceServer().jwt().jwtAuthenticationConverter(...).

solid answer

~40 s

The `JwtAuthenticationProvider` uses a `Converter<Jwt, AbstractAuthenticationToken>` — by default `JwtAuthenticationConverter`, which delegates to `JwtGrantedAuthoritiesConverter`. That converter reads the `scope` claim (or `scp`), splits it, and prefixes each value with `SCOPE_`, so a token with `scope: "read write"` yields authorities `SCOPE_read`, `SCOPE_write`. You then guard endpoints with `.hasAuthority("SCOPE_read")` or `.hasRole` semantics. To change this — e.g. map Keycloak's `realm_access.roles` to `ROLE_` authorities — you configure a custom `JwtGrantedAuthoritiesConverter` (setAuthorityPrefix, setAuthoritiesClaimName) or write your own converter that digs into nested claims, wrap it in a `JwtAuthenticationConverter`, and register it via `oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(myConverter)))`. You can also override the principal name (default `sub`) with `setPrincipalClaimName`.

code

java · 25 lines
java
// Map Keycloak realm_access.roles -> ROLE_ authorities, keep SCOPE_ scopes too.
static Converter<Jwt, ? extends AbstractAuthenticationToken> keycloakConverter() {
    JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); // SCOPE_*

    Converter<Jwt, Collection<GrantedAuthority>> combined = jwt -> {
        Collection<GrantedAuthority> auth = new ArrayList<>(scopes.convert(jwt));
        Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
        if (realm != null && realm.get("roles") instanceof Collection<?> roles) {
            roles.forEach(r -> auth.add(new SimpleGrantedAuthority("ROLE_" + r)));
        }
        return auth;
    };

    JwtAuthenticationConverter conv = new JwtAuthenticationConverter();
    conv.setJwtGrantedAuthoritiesConverter(combined);
    conv.setPrincipalClaimName("preferred_username");
    return conv;
}

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
        .oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(keycloakConverter())));
    return http.build();
}

go deeper

for a junior

Know that scopes become SCOPE_ authorities and you check them with hasAuthority.

for a middle

Configure setAuthorityPrefix / setAuthoritiesClaimName for a flat custom claim.

for a senior

Write a nested-claim converter (Keycloak roles), merge scopes+roles, override principal name.

for a principal

Standardize an authority-mapping convention across services so @PreAuthorize guards and gateway policies stay consistent regardless of IdP claim shapes.

## The conversion pipeline After `NimbusJwtDecoder` produces a validated `Jwt`, the `JwtAuthenticationProvider` must turn it into an `Authentication`. It calls a `Converter<Jwt, ? extends AbstractAuthenticationToken>`. The default implementation is **`JwtAuthenticationConverter`**, which produces a `JwtAuthenticationToken` and computes its authorities via an inner **`JwtGrantedAuthoritiesConverter`**. ## Default behavior: SCOPE_ from the scope claim `JwtGrantedAuthoritiesConverter` by default: - Reads the **`scope`** claim, or **`scp`** if `scope` is absent (both are standard OAuth2 spellings of granted scopes). - Accepts either a space-delimited string (`"read write"`) or a JSON array. - Prefixes each value with **`SCOPE_`**. So `scope: "orders:read orders:write"` → `SCOPE_orders:read`, `SCOPE_orders:write`. You authorize with: ```java .authorizeHttpRequests(a -> a.requestMatchers("/orders/**").hasAuthority("SCOPE_orders:read")) ``` There is also `.hasRole("X")` which internally checks `ROLE_X` — that prefix mismatch (`SCOPE_` vs `ROLE_`) is a frequent source of 403s. ## Customizing the prefix / claim name Many providers (notably Keycloak) don't put roles in `scope`. Keycloak uses nested claims: `realm_access.roles` (realm roles) and `resource_access.<client>.roles` (client roles). Two levels of customization: **1. Flat claim, different name/prefix** — reuse `JwtGrantedAuthoritiesConverter`: ```java JwtGrantedAuthoritiesConverter gac = new JwtGrantedAuthoritiesConverter(); gac.setAuthorityPrefix("ROLE_"); // instead of SCOPE_ gac.setAuthoritiesClaimName("roles"); // instead of scope JwtAuthenticationConverter conv = new JwtAuthenticationConverter(); conv.setJwtGrantedAuthoritiesConverter(gac); ``` **2. Nested claim** — the built-in converter can't reach into `realm_access.roles`, so write a `Converter<Jwt, Collection<GrantedAuthority>>` that extracts the nested list yourself and maps to `SimpleGrantedAuthority`. ## Wiring it in ```java http.oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(myConverter))); ``` Or, in a reactive app, `ReactiveJwtAuthenticationConverter` wrapped in a `Converter<Jwt, Mono<AbstractAuthenticationToken>>` via `.jwtAuthenticationConverter(...)` on the reactive DSL. ## Principal name `JwtAuthenticationToken.getName()` defaults to the `sub` claim. `JwtAuthenticationConverter.setPrincipalClaimName("preferred_username")` changes which claim becomes the principal name — useful when `sub` is an opaque UUID but you want a human-readable identity in logs/audit. ## Accessing the Jwt in controllers Once authenticated, inject `@AuthenticationPrincipal Jwt jwt` or `JwtAuthenticationToken` to read claims: `jwt.getClaimAsString("email")`, `jwt.getSubject()`, `jwt.getExpiresAt()`. ## Method security With `@EnableMethodSecurity`, `@PreAuthorize("hasAuthority('SCOPE_admin')")` works against these authorities — the pattern KataJob's module boundaries rely on for per-module `@PreAuthorize` guards. ## Gotchas - **SCOPE_ vs ROLE_ mismatch**: `hasRole('admin')` looks for `ROLE_admin`, but default mapping produces `SCOPE_admin`. Use `hasAuthority('SCOPE_admin')` or remap to `ROLE_`. - The default reads `scope`/`scp` only — Keycloak realm roles are invisible unless you supply a nested-claim converter. - Splitting: a string scope claim is space-delimited per OAuth2; don't assume commas. - If you register your own converter you lose the default SCOPE_ mapping entirely — merge both sets if you need scopes *and* roles. - Authorities are case-sensitive and prefix-sensitive; a stray lowercase or wrong prefix silently causes 403.

  • Why does .hasRole('admin') return 403 even though the token clearly has admin scope?
    hasRole('admin') checks for the authority ROLE_admin, but the default JwtGrantedAuthoritiesConverter emits SCOPE_admin. The prefixes don't match. Either authorize with hasAuthority('SCOPE_admin') or remap the authority prefix to ROLE_.
  • The default converter can't see Keycloak realm roles — why, and what's the fix?
    The default reads only the flat scope/scp claim; Keycloak roles live in the nested realm_access.roles object. Fix: write a custom Converter<Jwt, Collection<GrantedAuthority>> that extracts the nested list and set it on a JwtAuthenticationConverter.

saying these in an interview costs you the question

  • Assuming hasRole and SCOPE_ authorities are interchangeable
  • Thinking the default converter reads Keycloak realm_access.roles automatically
  • Believing scope claims are comma-delimited (they are space-delimited per OAuth2)
  • Forgetting that registering a custom converter drops the default SCOPE_ mapping unless you re-add it

context