Microsoft Entra ID (formerly Azure Active Directory) is the enterprise identity backbone for most corporate Azure tenants. If you are building APIs consumed by internal services, partners, or enterprise single sign-on portals, you will almost certainly need to validate Entra-issued JWTs. This post shows how to wire a Spring Boot 3 resource server to accept those tokens, enforce role-based access control, and handle the B2B service-to-service case — all with minimal boilerplate.
Prerequisites
Register the Application in Entra ID
Navigate to Entra ID → App registrations → New registration. Name it something like spring-api, set the audience to Accounts in this organizational directory only (single tenant), and leave the redirect URI blank — a resource server does not participate in the authorization code flow directly.
Once created, note two values from the Overview blade: the Application (client) ID and the Directory (tenant) ID. These become your client-id and the {tenantId} in the issuer URI.
Under Expose an API, click Add a scope. Set the scope name to api.read and grant consent for admins and users. Under App roles, define roles like API.ReadWrite and API.Admin — these land in the roles claim of every token issued to users or services assigned those roles.
Add the Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Spring Security's auto-configuration will detect the resource-server starter and wire a JwtDecoder bean automatically once you supply the issuer URI in application.yml.
Configure application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://login.microsoftonline.com/{tenantId}/v2.0
audiences: api://{clientId}
Spring Security fetches the OIDC discovery document from {issuer-uri}/.well-known/openid-configuration on startup, downloads the public keys, and from that point every incoming request bearing a Bearer token is validated automatically. The audiences constraint rejects tokens issued for a different application — a critical check when your tenant hosts multiple registrations.
Spring Security Configuration
The auto-configuration is enough for simple cases, but production APIs typically need custom authority mapping. Entra ID places application roles in the roles claim, not the standard scope claim. We need to tell Spring Security where to look.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("ROLE_API.Admin")
.anyRequest().hasAuthority("SCOPE_api.read")
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(converter()))
);
return http.build();
}
@Bean
JwtAuthenticationConverter converter() {
var rolesConverter = new JwtGrantedAuthoritiesConverter();
rolesConverter.setAuthoritiesClaimName("roles");
rolesConverter.setAuthorityPrefix("ROLE_");
var scopesConverter = new JwtGrantedAuthoritiesConverter();
// default: scp/scope claim with SCOPE_ prefix
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(token ->
Stream.concat(
rolesConverter.convert(token).stream(),
scopesConverter.convert(token).stream()
).collect(Collectors.toList())
);
return converter;
}
}
The key point here is the dual-converter setup. Entra ID puts delegated permissions (scopes) in the scp claim and application permissions (app roles) in the roles claim. Both are merged into the principal's authority set, which lets hasAuthority checks work cleanly for both user-delegated and service-to-service flows.
Service-to-Service (Client Credentials)
For machine-to-machine calls where no user is involved, the caller uses the OAuth 2.0 client credentials flow. The resulting token has no scp claim — only roles (app roles assigned to the service principal). The same SecurityConfig handles this transparently because we already map roles to ROLE_ authorities. The calling service requests the token like this:
curl -X POST \
https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token \
-d 'grant_type=client_credentials' \
-d 'client_id={callerClientId}' \
-d 'client_secret={callerSecret}' \
-d 'scope=api://{targetClientId}/.default'
The /.default scope tells Entra ID to issue a token with all application permissions that have been granted to the caller's service principal. Assign the API.ReadWrite app role to the caller in Entra ID — it will then appear in the token's roles claim and pass the hasAuthority("ROLE_API.ReadWrite") check.
Testing the Integration
The easiest way to validate locally is to obtain a token with the Azure CLI and fire it at your running service:
# Acquire a user-delegated token (interactive)
az login
TOKEN=$(az account get-access-token \
--resource api://{clientId} \
--query accessToken -o tsv)
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/me
Decode the token at jwt.ms to inspect the claims. Confirm iss matches your issuer URI, aud matches api://{clientId}, and your expected roles or scp values are present. If hasAuthority checks are failing, the mismatch is almost always in the claim name or prefix — add a @Slf4j and log authentication.getAuthorities() in a test endpoint to see exactly what Spring Security produced.
Wrapping Up
Spring Boot 3 plus the spring-boot-starter-oauth2-resource-server starter makes Entra ID integration surprisingly low-friction. The only non-obvious part is the dual authority converter — once you understand that scopes and app roles live in different claims, everything else falls into place.
aud claim — never accept any valid Entra token, only tokens issued for your specific app.kid.issuer-uri for jwk-set-uri and add a custom JwtIssuerAuthenticationManagerResolver.