Scopes, Roles and Authorities

Map token scopes and role claims to Spring Security authorities, and pick the right one of hasAuthority and hasRole for each rule.

SyntaxhasAuthority("SCOPE_orders:read") // from scope claim hasRole("admin") == hasAuthority("ROLE_admin") spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_

Spring Security makes every access decision from a list of GrantedAuthority strings on the current Authentication. For a JWT, a converter builds that list from the token's claims, and the default mapping surprises most people the first time.

The default: scopes become SCOPE_ authorities

JwtGrantedAuthoritiesConverter reads the scope claim, or scp if scope is absent, splits it on spaces and prefixes each value with SCOPE_. A token with "scope": "orders:read orders:write" produces SCOPE_orders:read and SCOPE_orders:write. A roles claim is ignored unless you map it.

.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read")
.requestMatchers(HttpMethod.POST, "/api/orders").hasAuthority("SCOPE_orders:write")

hasRole versus hasAuthority

hasRole("ADMIN") is shorthand for hasAuthority("ROLE_ADMIN"). Writing hasRole("orders:read") against scope authorities never matches, and the result is a puzzling 403 for a token that clearly has the scope. Use hasAuthority with the full string for scopes, and hasRole only for authorities that really start with ROLE_.

Scopes and roles mean different things

A scope limits what a client application may do on the user's behalf: a reporting dashboard might only get orders:read even when the user could write. A role describes the user: admin, support. Many APIs need both, for example "the client has orders:write and the user is in support".

Mapping a roles claim

If your issuer puts roles in a flat claim, Boot can switch the converter without code:

spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_

That replaces the scope mapping rather than adding to it. For a nested claim such as Keycloak's realm_access.roles, spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions takes SpEL expressions evaluated against the claims, for example [realm_access][roles]; it cannot be combined with authorities-claim-name, and one authority-prefix applies to every expression. To keep SCOPE_ scopes and ROLE_ roles side by side, write a converter and register it with jwt(jwt -> jwt.jwtAuthenticationConverter(...)), or declare a JwtAuthenticationConverter bean.

Mistakes worth avoiding

  • Trusting any claim the client controls. Only claims set by your authorization server count, and only after the signature check.
  • Checking roles only in the URL rules. Rules on paths are easy to bypass with a new endpoint; put critical checks on service methods with @PreAuthorize as well.
  • Checking ownership with roles. "Customer may read their own order" needs the token subject compared with the order's owner, not an authority.

Example

Example · java
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class AuthoritiesConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(AuthoritiesConfig::authorities);

        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read")
                        .requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_orders:write")
                        .requestMatchers("/api/admin/**").hasRole("admin")
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(converter)))
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(csrf -> csrf.disable())
                .build();
    }

    // Scopes keep the default SCOPE_ mapping; Keycloak realm roles become ROLE_ authorities.
    static Collection<GrantedAuthority> authorities(Jwt jwt) {
        List<GrantedAuthority> result = new ArrayList<>(new JwtGrantedAuthoritiesConverter().convert(jwt));

        Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
        if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> roles) {
            for (Object role : roles) {
                result.add(new SimpleGrantedAuthority("ROLE_" + role));
            }
        }
        return result;
    }
}

When to use it

  • A read-only analytics dashboard receives tokens with orders:read only, so it cannot create or cancel orders even for an admin user.
  • Support staff with a Keycloak realm role can issue refunds that ordinary customers cannot.
  • A customer may view only their own orders, checked by comparing the token subject with the order owner.

More examples

Mapping a flat roles claim with properties only

Boot builds a <code>JwtAuthenticationConverter</code> from these keys, and your chain picks it up with <code>jwt(Customizer.withDefaults())</code>. After this change there are no <code>SCOPE_</code> authorities any more. Changing the principal claim affects <code>Authentication.getName()</code>, so make sure that claim is unique and never reused.

Example · properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com
spring.security.oauth2.resourceserver.jwt.audiences=orders-api
# "roles": ["admin", "support"] becomes ROLE_admin, ROLE_support
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_
# Use the user name claim instead of sub as Authentication.getName()
spring.security.oauth2.resourceserver.jwt.principal-claim-name=preferred_username

Method security that combines a scope, a role and ownership

URL rules decide which endpoints a token can reach; method rules protect the operation wherever it is called from. <code>authentication.name</code> is the token's <code>sub</code> by default, which is what the ownership check needs. Remember to enable method security with <code>@EnableMethodSecurity</code>, or these annotations do nothing.

Example · java
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

record Refund(long orderId, String ownerId, long amountCents) {
}

@Service
public class RefundService {

    // The client must hold the write scope, and the user must either be support staff
    // or the customer who owns the order.
    @PreAuthorize("hasAuthority('SCOPE_orders:write') and (hasRole('support') or #refund.ownerId() == authentication.name)")
    public void requestRefund(Refund refund) {
        // create the refund
    }

    @PreAuthorize("hasRole('admin')")
    public void approveRefund(long refundId) {
        // approve it
    }
}

Testing authority rules with jwt()

Without <code>authorities(...)</code>, <code>jwt()</code> derives authorities from the token's <code>scope</code> claim the same way the default converter does, so the first test proves the mapping. The second test proves that a role does not stand in for a scope: a valid token with the wrong authority is a 403, not a 401.

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@WebMvcTest(OrdersAuthorityTest.OrdersController.class)
@Import({OrdersAuthorityTest.OrdersController.class, OrdersAuthorityTest.Rules.class})
class OrdersAuthorityTest {

    @RestController
    static class OrdersController {
        @GetMapping("/api/orders")
        String list() {
            return "[]";
        }
    }

    // A nested @Configuration would replace the application's configuration.
    @TestConfiguration
    static class Rules {
        @Bean
        SecurityFilterChain api(HttpSecurity http) throws Exception {
            return http
                    .authorizeHttpRequests(auth -> auth.anyRequest().hasAuthority("SCOPE_orders:read"))
                    .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
                    .build();
        }
    }

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    JwtDecoder jwtDecoder;

    @Test
    void scopeFromTheClaimIsEnough() {
        assertThat(mvc.get().uri("/api/orders").with(jwt().jwt(j -> j.claim("scope", "orders:read"))))
                .hasStatusOk();
    }

    @Test
    void roleAloneIsForbidden() {
        assertThat(mvc.get().uri("/api/orders")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))))
                .hasStatus(403);
    }
}

Discussion

  • Be the first to comment on this lesson.