Course – LSS – NPI (cat=Spring Security)
announcement - icon

If you're working on a Spring Security (and especially an OAuth) implementation, definitely have a look at the Learn Spring Security course:

>> LEARN SPRING SECURITY

1. Introduction

In this tutorial, we’ll show how to customize the mapping from JWT (JSON Web Token) claims into Spring Security’s Authorities.

2. Background

When a properly configured Spring Security-based application receives a request, it goes through a series of steps that, in essence, aims at two goals:

  • Authenticate the request, so the application can know who is accessing it
  • Decide whether the authenticated request may perform the associated action

For an application using JWT as its main security mechanism, the authorization aspect consists of:

  • Extracting claim values from the JWT payload, usually the scope or scp claim
  • Mapping those claims into a set of GrantedAuthority objects

Once the security engine has set up those authorities, it can then evaluate whether any access restrictions apply to the current request and decide whether it can proceed.

3. Default Mapping

Out-of-the-box, Spring uses a straightforward strategy to convert claims into GrantedAuthority instances. Firstly, it extracts the scope or scp claim and splits it into a list of strings. Next, for each string, it creates a new SimpleGrantedAuthority using the prefix SCOPE_ followed by the scope value.

To illustrate this strategy, let’s create a simple endpoint that allows us to inspect some key properties of the Authentication instance made available to the application:

@RestController
@RequestMapping("/user")
public class UserRestController {
    
    @GetMapping("/authorities")
    public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
        
        Collection<String> authorities = principal.getAuthorities()
          .stream()
          .map(GrantedAuthority::getAuthority)
          .collect(Collectors.toList());
        
        Map<String,Object> info = new HashMap<>();
        info.put("name", principal.getName());
        info.put("authorities", authorities);
        info.put("tokenAttributes", principal.getTokenAttributes());

        if ( principal instanceof AccountToken ) {
          info.put( "account", ((AccountToken)principal).getAccount());
        }

        return info;
    }
}

Here, we use a JwtAuthenticationToken argument because we know that, when using JWT-based authentication, this will be the actual Authentication implementation created by Spring Security. We create the result extracting from its name property, the available GrantedAuthority instances, and the JWT’s original attributes.

Now, let’s assume we invoke this endpoint passing and encoded-and-signed JWT containing this payload:

{
  "aud": "api://f84f66ca-591f-4504-960a-3abc21006b45",
  "iss": "https://sts.windows.net/2e9fde3a-38ec-44f9-8bcd-c184dc1e8033/",
  "iat": 1648512013,
  "nbf": 1648512013,
  "exp": 1648516868,
  "email": "[email protected]",
  "family_name": "Sevestre",
  "given_name": "Philippe",
  "name": "Philippe Sevestre",
  "scp": "profile.read",
  "sub": "eXWysuqIJmK1yDywH3gArS98PVO1SV67BLt-dvmQ-pM",
  ... more claims omitted
}

The response should look like a JSON object with three properties:

{
  "tokenAttributes": {
     // ... token claims omitted
  },
  "name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "authorities": [
    "SCOPE_profile",
    "SCOPE_email",
    "SCOPE_openid"
  ]
}

We can use those scopes to restrict access to certain parts of our applications by creating a SecurityFilterChain:

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    // @formatter:off
    return http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
                                .jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService)))).build();
    // @formatter:on
}

Notice that we’ve intentionally avoided using WebSecurityConfigureAdapter. As described, this class will be deprecated in Spring Security version 5.7, so it’s better to start moving to the new approach as soon as possible.

Alternatively, we could use method-level annotations and an SpEL expression to achieve the same result:

@GetMapping("/authorities")
@PreAuthorize("hasAuthority('SCOPE_profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
    // ... same code as before
}

Finally, for more complex scenarios, we can also resort to accessing directly the current JwtAuthenticationToken from which we have direct access to all GrantedAuthorities

4. Customizing the SCOPE_ Prefix

As our first example of how to change Spring Security’s default claim mapping behavior, let’s see how to change the SCOPE_ prefix to something else. As described in the documentation, there are two classes involved in this task:

  • JwtAuthenticationConverter: Converts a raw JWT into an AbstractAuthenticationToken
  • JwtGrantedAuthoritiesConverter: Extracts a collection of GrantedAuthority instances from the raw JWT.

Internally, JwtAuthenticationConverter uses JwtGrantedAuthoritiesConverter to populate a JwtAuthenticationToken with GrantedAuthority objects along with other attributes.

The simplest way to change this prefix is to provide our own JwtAuthenticationConverter bean, configured with JwtGrantedAuthoritiesConverter configured to one of our own choice:

@Configuration
@EnableConfigurationProperties(JwtMappingProperties.class)
@EnableMethodSecurity
public class SecurityConfig {
    // ... fields and constructor omitted
    @Bean
    public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
        JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
        if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
            converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix().trim());
        }
        return converter;
    }
    
    @Bean
    public JwtAuthenticationConverter customJwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter();
        return converter;
    }

Here, JwtMappingProperties is just a @ConfigurationProperties class that we’ll use to externalize mapping properties. Although not shown in this snippet, we’ll use constructor injection to initialize the mappingProps field with an instance populated from any configured PropertySource, thus giving us enough flexibility to change its values at deploy time.

This @Configuration class has two @Bean methods: jwtGrantedAuthoritiesConverter() creates the required Converter that creates the GrantedAuthority collection. In this case, we’re using the stock JwtGrantedAuthoritiesConverter configured with the prefix set in the configuration properties.

Next, we have customJwtAuthenticationConverter(), where we construct the JwtAuthenticationConverter configured to use our custom converter. From there, Spring Security will pick it up as part of its standard auto-configuration process and replace the default one.

Now, once we set the baeldung.jwt.mapping.authorities-prefix property to some value, MY_SCOPE, for instance, and invoke /user/authorities, we’ll see the customized authorities:

{
  "tokenAttributes": {
    // ... token claims omitted 
  },
  "name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "authorities": [
    "MY_SCOPE_profile",
    "MY_SCOPE_email",
    "MY_SCOPE_openid"
  ]
}

5. Using a Customized Prefix in Security Constructs

It is important to note that, by changing the authorities’ prefixes, we’ll impact any authorization rule that relies on their names. For instance, if we change the prefix to MY_PREFIX_, any @PreAuthorize expressions that assume the default prefix would no longer work. The same applies to HttpSecurity-based authorization constructs.

Fixing this issue, however, is simple. First, let’s add to our @Configuration class a @Bean method that returns the configured prefix. Since this configuration is optional, we must ensure that we return the default value if no one was given it:

@Bean
public String jwtGrantedAuthoritiesPrefix() {
  return mappingProps.getAuthoritiesPrefix() != null ?
    mappingProps.getAuthoritiesPrefix() : 
      "SCOPE_";
}

Now, we can use reference this bean using the @<bean-name> syntax in SpEL expressions. This is how we’d use the prefix bean with @PreAuthorize:

@GetMapping("/authorities")
@PreAuthorize("hasAuthority(@jwtGrantedAuthoritiesPrefix + 'profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
    // ... method implementation omitted
}

We can also use a similar approach when defining a SecurityFilterChain:

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    return http.authorizeHttpRequests(auth -> {
        auth.requestMatchers("/user/**")
          .hasAuthority(mappingProps.getAuthoritiesPrefix() + "profile");
      })
      // ... other customizations omitted
      .build();
}

6. Customizing the Principal‘s Name

Sometimes, the standard sub claim that Spring maps to the Authentication’s name property comes with a value that is not very useful. Keycloak-generated JWTs are a good example:

{
  // ... other claims omitted
  "sub": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "scope": "openid profile email",
  "email_verified": true,
  "name": "User Primo",
  "preferred_username": "user1",
  "given_name": "User",
  "family_name": "Primo"
}

In this case, sub comes with an internal identifier, but we can see that the preferred_username claim has a more friendly value. We can easily modify JwtAuthenticationConverter’s behavior by setting its principalClaimName property with the desired claim name:

@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());

    if (StringUtils.hasText(mappingProps.getPrincipalClaimName())) {
        converter.setPrincipalClaimName(mappingProps.getPrincipalClaimName());
    }
    return converter;
}

Now, if we set the baeldung.jwt.mapping.authorities-prefix property to “preferred_username”, the /user/authorities result will change accordingly:

{
  "tokenAttributes": {
    // ... token claims omitted 
  },
  "name": "user1",
  "authorities": [
    "MY_SCOPE_profile",
    "MY_SCOPE_email",
    "MY_SCOPE_openid"
  ]
}

7. Scope Names Mapping

Sometimes, we might need to map the scope names received in the JWT to an internal name. For example, this can be the case where the same application needs to work with tokens generated by different authorization servers, depending on the environment where it was deployed.

We might be tempted to extend JwtGrantedAuthoritiesConverter, but since this is a final class, we can’t use this approach. Instead, we must code our own Converter class and inject it into JwtAuthorizationConverter. This enhanced mapper, MappingJwtGrantedAuthoritiesConverter, implements Converter<Jwt, Collection<GrantedAuthority>> and looks much like the original one:

public class MappingJwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
    private static Collection<String> WELL_KNOWN_AUTHORITIES_CLAIM_NAMES = Arrays.asList("scope", "scp");
    private Map<String,String> scopes;
    private String authoritiesClaimName = null;
    private String authorityPrefix = "SCOPE_";
     
    // ... constructor and setters omitted

    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        
        Collection<String> tokenScopes = parseScopesClaim(jwt);
        if (tokenScopes.isEmpty()) {
            return Collections.emptyList();
        }
        
        return tokenScopes.stream()
          .map(s -> scopes.getOrDefault(s, s))
          .map(s -> this.authorityPrefix + s)
          .map(SimpleGrantedAuthority::new)
          .collect(Collectors.toCollection(HashSet::new));
    }
    
    protected Collection<String> parseScopesClaim(Jwt jwt) {
       // ... parse logic omitted 
    }
}

Here, the key aspect of this class is the mapping step, where we use the supplied scopes map to translate the original scopes into the mapped ones. Also, any incoming scope that has no mapping available will be preserved.

Finally, we use this enhanced converter in our @Configuration in its jwtGrantedAuthoritiesConverter() method:

@Bean
public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
    MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(mappingProps.getScopes());

    if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
        converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix());
    }
    if (StringUtils.hasText(mappingProps.getAuthoritiesClaimName())) {
        converter.setAuthoritiesClaimName(mappingProps.getAuthoritiesClaimName());
    }
    return converter;
}

8. Using a Custom JwtAuthenticationConverter

In this scenario, we’ll take full control of the JwtAuthenticationToken generation process. We can use this approach to return an extended version of this class with additional data recovered from a database.

There are two possible approaches to replace the standard JwtAuthenticationConverter. The first, which we’ve used in the previous sections, is to create a @Bean method that returns our custom converter. This, however, implies that our customized version must extend Spring’s JwtAuthenticationConverter so the autoconfiguration process can pick it.

The second option is to use the HttpSecurity-based DSL approach, where we can provide our custom converter. We’ll do this using the oauth2ResourceServer customizer, which allows us to plug any converter that implements a much more generic interface Converter<Jwt, AbstractAuthorizationToken>:

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    // @formatter:off
    return http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
                                .jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService)))).build();
    // @formatter:on
}

Our CustomJwtAuthenticationConverter uses an AccountService (available online) to retrieve an Account object based on username claim value. It then uses it to create a CustomJwtAuthenticationToken with an extra accessor method for the account data:

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    // ...private fields and construtor omitted
    @Override
    public AbstractAuthenticationToken convert(Jwt source) {
        
        Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(source);
        String principalClaimValue = source.getClaimAsString(this.principalClaimName);
        Account acc = accountService.findAccountByPrincipal(principalClaimValue);
        return new AccountToken(source, authorities, principalClaimValue, acc);
    }
}

Now, let’s modify our /user/authorities handler to use our enhanced Authentication:

@GetMapping("/authorities")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {

    // ... create result map as before (omitted)
    if (principal instanceof AccountToken) {
        info.put( "account", ((AccountToken)principal).getAccount());
    }
    return info;
}

One advantage of taking this approach is that we can now easily use our enhanced authentication object in other parts of the application. For instance, we can access the account info in SpEL expressions directly from the built-in variable authentication:

@GetMapping("/account/{accountNumber}")
@PreAuthorize("authentication.account.accountNumber == #accountNumber")
public Account getAccountById(@PathVariable("accountNumber") String accountNumber, AccountToken authentication) {
    return authentication.getAccount();
}

Here, the @PreAuthorize expression enforces that the accountNumber passed in the path variable belongs to the user. This approach is particularly useful when used in conjunction with Spring Data JPA, as described in the official documentation.

9. Testing Tips

The examples given so far assume we have a functioning identity provider (IdP) that issues JWT-based access tokens. A good option is to use the embedded Keycloak server that we’ve already covered here. Additional configuration instructions are also available in our Quick Guide to Using Keycloak.

Please notice that those instructions cover how to register an OAuth client. For live tests, Postman is a good tool that supports the authorization code flow. The important detail here is how to properly configure the Valid Redirect URI parameter. Since Postman is a desktop application, it uses a helper site located at https://oauth.pstmn.io/v1/callback to capture the authorization code. Consequently, we must ensure we have internet connectivity during the tests. If this is not possible, we can use the less secure password grant flow instead.

Regardless of the selected IdP and client selection, we must configure our resource server so it can properly validate the received JWTs. For standard OIDC providers, this means providing a suitable value to the spring.security.oauth2.resourceserver.jwt.issuer-uri property. Spring will then fetch all configuration details using the .well-known/openid-configuration document available there.

In our case, the issuer URI for our Keycloak realm is http://localhost:8083/auth/realms/baeldung. We can point our browser to retrieve the full document at http://localhost:8083/auth/realms/baeldung/.well-known/openid-configuration.

10. Conclusion

In this article, we’ve shown different ways to customize the way Spring Security map authorities from JWT claims. As usual, complete code is available over on GitHub.

Course – LSS (cat=Security/Spring Security)

I just announced the new Learn Spring Security course, including the full material focused on the new OAuth2 stack in Spring Security:

>> CHECK OUT THE COURSE
res – Security (video) (cat=Security/Spring Security)
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.