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.
hasAuthority("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
@PreAuthorizeas 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
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;
public class AuthoritiesConfig {
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.
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.
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.
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