In this blog, we’ll explore how to configure a .NET REST API to authenticate requests using Microsoft Entra ID (formerly known as Azure AD). With a step-by-step guide, we’ll cover everything from configuration and authentication setup to enabling OAuth2 Authorization Code Flow with PKCE for interactive API testing in Scalar (OpenAPI UI).
You’ll learn how to:
• Authenticate API calls with Microsoft Entra ID-issued JWTs
• Enable OAuth2 Authorization Code flow from Scalar UI (OpenAPI) using PKCE
• Maintain compatibility with existing template toggles via compile-time symbol ExtLogin
Let’s dive into the details of getting this all set up.
What this Setup Provides
• Authenticate REST API calls with Microsoft Entra ID-issued JWTs
• Enable OAuth2 Authorization Code flow from Scalar UI (OpenAPI) using PKCE
• Keep compatibility with our existing template toggles via compile-time symbol ExtLogin
Prerequisites
- An App Registration for the API
- Exposed API permissions (scopes), such as
api://{api-client-id}/Scalarand/orapi://{api-client-id}/default - The API’s Client (Application) ID and Tenant ID
- Issuer/authority details, e.g.
https://login.microsoftonline.com/{tenantId}/v2.0
Configuration Model: OpenIdOptions
A dedicated options model holds the OpenID Connect settings for Entra ID:
public class OpenIdOptions
{
public string Url { get; set; } = string.Empty; // Authority
public string IssuerUri { get; set; } = string.Empty; // Issuer
public string Scope { get; set; } = string.Empty; // Optional default scope name
public bool ValidateAudience { get; set; } = false;
public string? ValidAudience { get; set; }
public bool ValidateIssuer { get; set; } = true;
public bool ValidateIssuerSigningKey { get; set; } = true;
// Optional: map roles/groups from a custom claim name
public string? RoleClaim { get; set; }
// Used by OAuth for Scalar and OIDC flows
public string ClientId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
}
App Configuration: OpenIdSettings
Add the Entra ID configuration to appsettings.json (or secrets/environment config). Example:
{
"OpenIdSettings": {
"Url": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"IssuerUri": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"ValidateAudience": true,
"ValidAudience": "api://{api-client-id}",
"ValidateIssuer": true,
"ValidateIssuerSigningKey": true,
"RoleClaim": "roles", // or "groups" if you map group IDs to roles
"ClientId": "{api-client-id}",
"TenantId": "{tenant-id}"
}
}
- Url (authority) and IssuerUri should match the tokens you will receive (v2 endpoints recommended).
- If you validate audience, set ValidAudience to your API’s “Application ID URI” (often api://{clientId} or a custom URI).
- RoleClaim is optional and used if you want to convert Entra roles/groups claims into your internal Role[] enum.
Configuring JWT Authentication
Add JWT bearer authentication so the API trusts tokens issued by Entra ID:
private static IServiceCollection AddAuthentication(this IServiceCollection srv, IConfiguration configuration)
{
JwtSecurityTokenHandler.DefaultMapInboundClaims = false; // keep standard JWT claim names
srv.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
var ido = configuration.GetSection(ConfigurationKeys.OpenIdOptions).Get<OpenIdOptions>();
options.Authority = ido.Url; // e.g. https://login.microsoftonline.com/{tenantId}/v2.0
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(5),
ValidateAudience = ido.ValidateAudience,
ValidAudience = ido.ValidAudience,
ValidateIssuer = ido.ValidateIssuer,
ValidateIssuerSigningKey = ido.ValidateIssuerSigningKey,
ValidIssuer = ido.IssuerUri
};
});
return srv;
}
This instructs the API to accept and validate JWTs issued by Entra ID for your tenant and API audience.
OpenAPI/Scalar: OAuth2 Authorization Code Flow with PKCE
Scalar (OpenAPI UI) can be configured for interactive testing with Entra ID. This requires an OAuth2 AuthorizationCode flow definition with PKCE.
Example OpenAPI Transformer
internal sealed class OpenApiSecuritySchemeTransformer(IConfiguration cfg) : IOpenApiDocumentTransformer
{
private const string AuthenticationScheme = "Bearer";
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
{
var options = cfg.GetSection(ConfigurationKeys.OpenIdOptions).Get<OpenIdOptions>()
?? throw new InvalidOperationException($"{ConfigurationKeys.OpenIdOptions} should be configured for OAuth to work");
var tenantId = options.TenantId;
var apiClientId = options.ClientId;
var apiAccessScope = $"api://{apiClientId}/Scalar"; // example exposed scope
var defaultScope = $"api://{apiClientId}/default"; // optional
var securitySchema = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Scheme = AuthenticationScheme,
BearerFormat = "JWT",
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new($"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize"),
TokenUrl = new($"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"),
Scopes = new Dictionary<string, string>
{
[apiAccessScope] = "Access Demo API",
[defaultScope] = "Default access",
["openid"] = "OpenID",
["profile"] = "Profile access"
},
Extensions = new Dictionary<string, IOpenApiExtension>()
{
["x-usePkce"] = new OpenApiString("SHA-256"),
}
}
}
};
document.SecurityRequirements.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Id = AuthenticationScheme, Type = ReferenceType.SecurityScheme }
},
[apiAccessScope]
}
});
document.Components = new OpenApiComponents
{
SecuritySchemes = new Dictionary<string, OpenApiSecurityScheme> { { AuthenticationScheme, securitySchema } }
};
return Task.CompletedTask;
}
}
Scalar UI Wiring and Selecting Scopes
public static IEndpointRouteBuilder MapScalar(this IEndpointRouteBuilder builder, IConfiguration cfg)
{
var openIdOptions = cfg.GetSection(ConfigurationKeys.OpenIdOptions).Get<OpenIdOptions>()
?? throw new InvalidOperationException($"{ConfigurationKeys.OpenIdOptions} should be configured for Scalar to work");
builder.MapScalarApiReference(options =>
{
options.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
options.AddAuthorizationCodeFlow(JwtBearerDefaults.AuthenticationScheme, flow =>
{
flow.ClientId = openIdOptions.ClientId;
flow.SelectedScopes = [$"api://{openIdOptions.ClientId}/Scalar"];
});
});
return builder;
}
App Startup
srv.AddOpenApi(options =>
{
options.AddDocumentTransformer<OpenApiSecuritySchemeTransformer>();
});
app.MapOpenApi();
app.MapScalar(app.Configuration);
Now you can “Authorize” directly in Scalar, complete the PKCE flow against Entra ID, and have the UI attach access tokens to calls.
Common Pitfalls and Troubleshooting
- Audience/issuer mismatch:
- Ensure the API’s “Application ID URI” matches ValidAudience and the aud in your tokens.
- Use v2.0 endpoints consistently for both authorize and token URLs.
- Role vs. group claims:
- If you configured App Roles, users get roles claim. Map RoleClaim = “roles” and align enum names.
- If you rely on Entra groups, you’ll get groups claim with GUID values. Maintain a GUID→role map.
- Claim mapping surprises:
- Keep JwtSecurityTokenHandler.DefaultMapInboundClaims = false to avoid legacy claim URI remapping.
- Scalar not showing “Authorize” or failing:
- Verify OpenIdSettings are present.
- Confirm the scope used in SelectedScopes is actually exposed by your API app registration and granted to users.
Summary
To integrate Microsoft Entra ID with a .NET API:
- Define an
OpenIdOptionsmodel and configure it fromappsettings. - Add JWT bearer authentication pointing to Entra ID’s authority.
- Configure Scalar/OpenAPI for OAuth2 Authorization Code flow with PKCE.
- Map roles or groups based on your organization’s identity policy.
Use the code snippets above as a starting point, adjust scopes and claims to your organization’s policy, and you’ll have a production-ready, Entra-backed API flow end-to-end.
Also, you can feel free to contact Trailhead for any advice relate to topic!


