Ronald Bosma
Ronald Bosma
Software Architect
Sep 16, 2025 11 min read

Protect APIs in Azure API Management with OAuth

thumbnail for this post

I’ve been working on securing APIs in Azure API Management and wanted to show you how to deploy a complete OAuth-protected setup using Bicep. While Microsoft’s documentation covers how to protect an API in Azure API Management using OAuth 2.0, it focuses on manual configuration through the Azure portal.

In this post, I’ll show you how to deploy everything using Bicep, including the necessary Microsoft Entra ID app registrations. This approach gives you a fully automated, repeatable infrastructure-as-code solution for protecting your APIs with OAuth.

This is the first post in a series about OAuth and API Management where we’ll explore different scenarios and implementation patterns.

Table of Contents

Why Protect APIs with OAuth

API Management provides several security mechanisms, but OAuth 2.0 with Microsoft Entra ID offers robust protection for enterprise scenarios. Here’s what you get:

  • Token-based authentication: Clients authenticate with Entra ID and receive access tokens
  • Role-based authorization: Fine-grained control over API operations based on application roles
  • Centralized identity management: Integration with your organization’s identity provider
  • Token validation: API Management validates tokens without calling back to Entra ID for each request
  • Managed identity integration: Azure resources that support managed identities can easily access APIs protected by OAuth 2.0 with Entra ID

The traditional approach requires manual configuration of app registrations in Entra ID, which can be error-prone and difficult to reproduce across environments. Using Bicep with the Microsoft Graph extension solves this by treating identity configuration as infrastructure-as-code.

It’s worth noting that while this post focuses on API Management, the same Entra ID configuration can be used to protect other types of APIs, such as Azure Functions, ASP.NET Core Web APIs, or any other application that supports OAuth 2.0 authentication with Entra ID.

Solution Overview

The solution deploys the following architecture:

Overview

The complete solution includes:

  • An API Management service with an OAuth-protected API
  • Three Entra ID app registrations:
    • One app registration representing the APIs in API Management
    • One client with ‘read’ and ‘write’ permissions
    • One client with no API access (for testing authorization failures)

To make deployment easier, I’ve created an Azure Developer CLI (azd) template: Protect API Management with OAuth. If you want to use it, check out the getting started section for the prerequisites and deployment instructions.

Entra ID Configuration

The key to deploying Entra ID resources with Bicep is the Microsoft Graph Bicep Extension, which has recently been released as GA. This extension allows you to manage Microsoft Graph resources like app registrations directly from Bicep templates.

To enable the extension, you need to add it to your bicepconfig.json file:

{
  "extensions": {
    "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0"
  }
}

This configuration tells Bicep to load the Microsoft Graph extension, making the Microsoft.Graph resource types available in your templates.

The solution creates three app registrations with specific purposes.

API Management App Registration

The Microsoft.Graph/applications resource creates an app registration in Entra ID that represents the API Management service and is configured with application roles. The corresponding Microsoft.Graph/servicePrincipals resource can be found under ‘Enterprise Applications’ in Entra ID:

extension microsoftGraphV1

resource apimAppRegistration 'Microsoft.Graph/applications@v1.0' = {
  uniqueName: name
  displayName: name
  identifierUris: [ identifierUri ]

  api: {
    requestedAccessTokenVersion: 2 // Issue OAuth v2.0 access tokens
  }

  appRoles: [
    {
      id: guid(tenantId, name, 'Sample.Read')
      description: 'Sample read application role'
      displayName: 'Sample.Read'
      value: 'Sample.Read'
      allowedMemberTypes: [ 'Application' ]
      isEnabled: true
    }
    {
      id: guid(tenantId, name, 'Sample.Write')
      description: 'Sample write application role'
      displayName: 'Sample.Write'
      value: 'Sample.Write'
      allowedMemberTypes: [ 'Application' ]
      isEnabled: true
    }
    {
      id: guid(tenantId, name, 'Sample.Delete')
      description: 'Sample delete application role'
      displayName: 'Sample.Delete'
      value: 'Sample.Delete'
      allowedMemberTypes: [ 'Application' ]
      isEnabled: true
    }
  ]
  
  // Add a 'HideApp' tag to hide the app from the end-users in the My Apps portal
  tags: ['HideApp']
}

resource apimServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = {
  appId: apimAppRegistration.appId
  appRoleAssignmentRequired: true // When true, clients must have an app role assigned in order to retrieve an access token
}

Key configuration points:

  • The identifierUris property sets the Application ID URI, such as api://apim-oauth-uks-ledm7. When requesting an access token from Entra ID, you need to use this URI with the /.default suffix as the scope (for example, api://apim-oauth-uks-ledm7/.default)
  • The requestedAccessTokenVersion is set to 2 for OAuth 2.0 tokens
  • Three application roles are defined: Sample.Read, Sample.Write and Sample.Delete for different API operations
  • The guid(tenantId, name, 'Sample.Read') function generates a unique deterministic ID, ensuring the app role ID remains consistent across deployments
  • The HideApp tag prevents the app from appearing in the My Apps portal for end-users
  • The appRoleAssignmentRequired property ensures only clients with assigned roles can get tokens

After deployment, you can see the created app registration in the Azure portal with the client ID and identifier URI:

APIM App Registration Details

In this image, you can see two important values highlighted:

  • Application (client) ID: This is the value of the audience claim in the JWT token and can be used to verify that the token was issued for this specific app registration
  • Application ID URI: This is the scope to use when retrieving an access token from Entra ID

The available roles are also visible in the app registration under the “App roles” section:

App Roles

For the complete implementation of the API Management app registration, see the apim-app-registration.bicep template in the project repository. The Bicep file uses a variable to define the roles and a for loop to create them, which makes it easier to manage the roles and keeps the code readable.

Naming tip: Don’t use the exact name of your API Management service for the app registration. When you enable the system-assigned managed identity on a resource like API Management, a service principal with the same name is created. Using the same name for the app registration would result in two service principals with the same name, which can cause issues when you’re trying to assign permissions.

Client App Registrations

You can create an app registration for a client application using Bicep. The following example demonstrates how to define such a client app registration:

resource clientAppRegistration 'Microsoft.Graph/applications@v1.0' = {
  uniqueName: name
  displayName: name
  
  // Add a 'HideApp' tag to hide the app from the end-users in the My Apps portal
  tags: ['HideApp']
}

resource clientServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = {
  appId: clientAppRegistration.appId

  // Enforces that users/clients must be assigned an app role to access the application.
  // This is not strictly required for this scenario, but it adds an extra layer of security.
  appRoleAssignmentRequired: true
}

For the complete client app registration implementation, I’ve created a reusable Bicep module client-app-registration.bicep that can be used to create both the valid client (with permissions) and the invalid client (without permissions) for testing different authorization scenarios.

Assigning roles can be done using the Microsoft.Graph/appRoleAssignedTo resource. Here’s how to assign the Sample.Read and Sample.Write roles to the a client:

func getAppRoleIdByValue(appRoles array, value string) string =>
  first(filter(appRoles, (role) => role.value == value)).id

resource assignSampleReadToValidClient 'Microsoft.Graph/appRoleAssignedTo@v1.0' = {
  resourceId: apimServicePrincipal.id
  appRoleId: getAppRoleIdByValue(apimAppRegistration.appRoles, 'Sample.Read')
  principalId: clientServicePrincipal.id
}

resource assignSampleWriteToValidClient 'Microsoft.Graph/appRoleAssignedTo@v1.0' = {
  resourceId: apimServicePrincipal.id
  appRoleId: getAppRoleIdByValue(apimAppRegistration.appRoles, 'Sample.Write')
  principalId: clientServicePrincipal.id
}

The getAppRoleIdByValue function helps you retrieve the app role ID based on its value. Since role assignments require the unique role ID, this function lets you reference roles by name (such as ‘Sample.Read’), making your Bicep templates easier to read and reusable across different environments where the role IDs may vary.

Note that assigning roles immediately after creating the app registrations can fail because the service principals might not be provisioned yet. In the template, I’ve worked around this by assigning the roles after API Management is deployed because that takes some time. In the future, I’m hoping to use the waitUntil decorator instead.

For the complete role assignment implementation, see the assign-app-roles.bicep template in the project repository.

Once deployed, you can verify the role assignments in the Azure portal under the valid client app’s API permissions:

Client API Permissions

Important limitation: The Microsoft Graph Bicep extension doesn’t support generating client secrets or certificates. This means you’ll need to manually create these for your client applications after deployment—either through the Azure portal (in the “Certificates & secrets” section of each client app registration) or by using the Azure CLI.

API Management Policy Configuration

The API is protected using an API Management policy that validates OAuth tokens and enforces role-based access. Here’s a simplified example showing the core validation logic:

<policies>
    <inbound>
        <base />

        <!-- Validate the JWT token -->
        <validate-azure-ad-token tenant-id="{{tenant-id}}">
            <audiences>
                <audience>{{oauth-audience}}</audience>
            </audiences>
            <required-claims>
                <claim name="roles" match="any">
                    <value>Sample.Read</value>
                </claim>
            </required-claims>
        </validate-azure-ad-token>
    </inbound>
</policies>

The validate-azure-ad-token policy uses two named values to verify token authenticity: tenant-id contains your Directory (tenant) ID to ensure the token was issued by the correct identity provider, and oauth-audience contains the ‘Application (client) ID’ of the API Management app registration to verify the token was retrieved for the expected resource. This combination ensures that only tokens issued by your tenant for the correct API Management app registration are accepted. The policy also checks that the token’s roles claim includes the required role, such as Sample.Read.

In production scenarios, I usually configure the validate-azure-ad-token policy at the global scope to enforce OAuth authentication for all APIs in the API Management instance, then configure the specific required roles at the API or operation scope by setting them in a variable before the token validation occurs. This approach provides consistent security across your APIs while making it easy to enforce different role requirements for different endpoints or operations, keeping your policy logic both centralized and flexible.

You can find the full policy example with role determination based on HTTP methods in the project repository.

As an alternative to the validate-azure-ad-token policy, you can use the validate-jwt policy, which supports other identity providers that implement OpenID Connect:

<validate-jwt header-name="Authorization">
    <openid-config url="https://login.microsoftonline.com/{{tenant-id}}/v2.0/.well-known/openid-configuration" />
    <audiences>
        <audience>{{oauth-audience}}</audience>
    </audiences>
    <required-claims>
        <claim name="roles" match="any">
            <value>Sample.Read</value>
        </claim>
    </required-claims>
</validate-jwt>

Testing the Protected API

After deployment, you can test the OAuth-protected API using the OAuth 2.0 client credentials flow. The following sequence diagram shows the authentication and authorization flow:

Sequence Diagram

For a detailed explanation on how to test the API with the VS Code REST Client extension, see the ‘Test the protected API’ section in the template’s demo guide. But you can use any tool that supports HTTP requests.

Here are the basic HTTP requests you’ll use:

  1. Get an access token from Entra ID:
# Get a token from Entra ID
# @name getToken
POST https://login.microsoftonline.com/{{tenantId}}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

client_id={{clientId}}&client_secret={{clientSecret}}&grant_type=client_credentials&scope={{clientScope}}
  1. Call the protected API with different HTTP methods:
# Call GET on Protected API with token
GET https://{{apimHostname}}/protected
Authorization: Bearer {{getToken.response.body.access_token}}

# Call POST on Protected API with token
POST https://{{apimHostname}}/protected
Authorization: Bearer {{getToken.response.body.access_token}}

# Call DELETE on Protected API with token (should fail without Sample.Delete role)
DELETE https://{{apimHostname}}/protected
Authorization: Bearer {{getToken.response.body.access_token}}

The API will return a 200 OK response if the token is valid and the client has the required role, or a 401 Unauthorized response if authorization fails.

You can inspect the access token at jwt.ms to see the claims, including the roles claim that contains the assigned application roles.

Considerations

When implementing OAuth protection for API Management, there are several architectural decisions to consider that will impact your security model, performance and maintenance overhead.

App Registration Strategy

You’ll need to decide how many app registrations to create for your APIs.

Single app registration approach: Create one app registration that represents all APIs in your API Management instance, with different roles for each API or set of operations. This is the approach shown in this post. The main benefit is that clients only need to retrieve one token to access multiple APIs they’re authorized for, reducing the number of calls to Entra ID. However, if clients have many role assignments, the JWT token can become large, potentially impacting performance or causing issues with firewalls that limit header sizes.

Multiple app registrations approach: Create a separate app registration for each API in API Management. This results in smaller JWT tokens with fewer roles, but clients need to retrieve and cache separate tokens for each API since the OAuth 2.0 client credentials flow in Entra ID only supports one scope per token request. This also increases the number of app registrations you need to maintain.

Client Authentication Methods

The authentication method your clients use affects both security and implementation complexity.

Managed identities should always be your first choice when clients run on Azure resources within the same Entra ID tenant. This eliminates secret management entirely and provides the highest security with the least operational overhead.

Certificates are Microsoft’s recommended best practice over client secrets. The private key stays with the client and doesn’t travel over the network, making it more secure. However, certificate management can be more complex for clients to implement.

Client secrets are the simplest option from a client implementation perspective but less secure since the secret is transmitted with each token request. You’ll also need to plan for secret rotation since client secrets have expiration dates, and you need a secure way to distribute the secrets to your clients.

Federated credentials can be a good alternative for external clients that support this authentication method, though the setup can be more complex.

If you want to enforce that clients use certificates or managed identities instead of client secrets, you can add an additional check in your API Management policy that requires the azpacr claim in the token to be 2, indicating that certificate-based authentication was used.

Conclusion

Deploying OAuth-protected APIs in Azure API Management using Bicep provides several benefits:

  • Infrastructure as code: You can reproduce your environment, including identity configuration, every time you deploy
  • Role-based security: Fine-grained access control using application roles
  • Enterprise integration: Native integration with Microsoft Entra ID

This approach eliminates manual configuration steps and provides a solid foundation for securing APIs in enterprise environments. In upcoming posts in this series, we’ll explore additional OAuth scenarios.

You can find the complete working example in my protect-apim-with-oauth repository, which includes detailed deployment instructions and testing examples.