YASITH.SYS

// log_entry

Implementing OAuth 2.0 SSO with Spring Boot and Microsoft Entra ID

April 18, 2026[~8m]
#Spring Boot#OAuth 2.0#Security#Java

A practical guide to integrating Microsoft Entra ID as an OAuth 2.0 / OIDC provider in a Spring Boot application — covering token validation, role-based access control, and securing B2B APIs.

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

  • Java 17+ and Spring Boot 3.2+
  • An Azure subscription — Contributor role on the Entra ID tenant
  • Familiarity with OAuth 2.0 authorization code and client-credentials flows
  • 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.

  • Always validate the aud claim — never accept any valid Entra token, only tokens issued for your specific app.
  • Use app roles for machine-to-machine permissions, and delegated scopes for user-context permissions.
  • Tokens are cached by MSAL / az CLI, but keys rotate on the Entra side — Spring Security automatically refreshes JWKS keys when it encounters an unknown kid.
  • For multi-tenant apps, swap issuer-uri for jwk-set-uri and add a custom JwtIssuerAuthenticationManagerResolver.