A token can have a valid signature and still belong to another API. A caller can authenticate successfully and still lack permission to write an order. I want those failures to remain separate, both in the implementation and in the HTTP response.
I built entra-id-api-dotnet, a small ASP.NET Core 8 API demonstrating JWT validation and independent scope and role policies. Its 14 integration cases pass in GitHub Actions CI. They exercise the actual JWT middleware with locally signed tokens; they do not establish a working connection to a live Microsoft Entra tenant.
Here is how I set up that boundary and what the tests actually demonstrate.
Start with one tenant and one audience
The application requires two configuration values: Entra:TenantId and Entra:Audience. Startup rejects an invalid or empty tenant GUID and a blank audience. The README specifies the API application's client ID GUID as the audience for v2 access tokens.
I construct the authority from that tenant: https://login.microsoftonline.com/{tenant}/v2.0. This sample uses a single configured issuer. It does not select a tenant from an incoming token or maintain a list of accepted organizations.
The registration instructions request v2 access tokens, expose the delegated scope api.read, and define an application role named Orders.Write. A separate caller registration receives the appropriate permissions and consent. The API validates incoming credentials; it does not need a client secret to perform that validation.
The configuration fields start empty. Environment variables can supply their values, allowing deployment settings to stay outside the source tree.
Validate before trusting claims
Decoding a JWT is useful for inspection, but the decoded payload is not evidence that its claims are trustworthy. I configure JWT bearer authentication before using those claims in authorization policies.
This excerpt from Program.cs shows the validation parameters:
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = audience,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
ValidateLifetime = true,
RequireExpirationTime = true,
ClockSkew = TimeSpan.FromSeconds(30),
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
NameClaimType = "name",
RoleClaimType = "roles"
The surrounding configuration requires HTTPS metadata and disables inbound claim mapping. Production authentication uses discovery at the configured authority to obtain signing keys. The API therefore checks the issuer, intended audience, signature and lifetime rather than accepting a payload because it contains a convincing role name.
The algorithm restriction is explicit: this implementation accepts RS256. The tests use that algorithm too; they do not independently exercise rejection of every other algorithm.
I also keep the middleware order visible: authentication runs before authorization. Each protected endpoint names its policy, while /health explicitly allows anonymous access.
Match a complete delegated scope
The read endpoint requires api.read. A scope claim can contain several space-separated permissions, so comparing the entire claim to one string would reject a legitimate combination. Searching for a substring would introduce the opposite problem: api.read.extra could accidentally satisfy api.read.
My ApiRead policy uses this assertion:
.RequireAssertion(context => context.User.FindAll("scp")
.SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Contains("api.read", StringComparer.Ordinal)));
It splits every scp claim into individual values and performs an exact, case-sensitive comparison. The policy also requires an authenticated user. A scope string alone cannot bypass token validation.
The tests include a successful token containing profile api.read and a rejected token containing api.read.extra. Those cases demonstrate the distinction between a complete permission and a similar-looking value. Case sensitivity is explicit in the implementation; the suite does not include a separate mixed-case input.
Give the write endpoint its own policy
The order endpoint requires the role Orders.Write. With RoleClaimType configured as roles, the policy is small:
options.AddPolicy("OrdersWrite", policy => policy
.RequireAuthenticatedUser()
.RequireRole("Orders.Write"));
I attach it to POST /orders, independently of the read policy. A valid caller missing the scope can still satisfy the order policy through its role. A valid caller missing the role can still satisfy the read policy through its scope. Both directions have test cases.
The README describes configuring the role for application access. The endpoint itself checks authentication and role membership; it does not additionally verify that the token came from a client-credentials flow. The test token builder normally includes both scope and role claims. These tests establish independent permission checks, not a complete distinction between delegated and application-only token types.
Make rejection behavior predictable
Missing or invalid credentials produce 401 responses on protected endpoints. The tests also check that these responses include a Bearer authentication challenge.
A token expired ten minutes earlier receives 401. The configured 30-second clock tolerance means I would not describe expiration as rejection at the exact instant of the timestamp. Boundary timing is not separately tested.
A different audience, an untrusted signing key and an incorrect issuer also receive 401. An authenticated token without the endpoint's permission receives 403. That distinction helps a caller separate authentication failure from insufficient authorization.
A token issued by another tenant would not match the single configured issuer. However, the negative test supplies https://untrusted.example/v2.0 as the issuer. It does not obtain a real token from another Entra tenant. The configured restriction is visible in code; cross-tenant integration remains unverified.
What the 14 cases prove
The suite uses xUnit and WebApplicationFactory to send HTTP requests through the application. Its factory creates temporary 2048-bit RSA keys, signs test JWTs, and supplies static discovery metadata containing the trusted public key. A separate key generates the untrusted-signature case.
The factory replaces discovery and signing-key material while preserving the production validation flags, issuer, audience, algorithm restriction and authorization policies. Identity-network requests are explicitly blocked. This avoids making local tests depend on tenant credentials or network availability.
One parameterized test has 14 inputs covering public health, anonymous requests, valid access, invalid credentials and independent permission failures. All 14 pass in CI at the revision cited below.
The GitHub Actions workflow runs the Release tests with .NET 8 on Ubuntu, and the run for that revision passed. Live discovery, key rotation, real consent flows and real tenant tokens still need integration validation.
Keep the sample's limits visible
The order endpoint returns an acknowledgement; it does not persist orders. These policies also do not implement ownership checks, per-customer data isolation or caller-application allowlists. Those decisions belong beside the relevant business operation.
All three excerpts come from Program.cs. The tests and README are pinned to the same revision, 5f05783815b8f7e727c3f0b7cde723f35788ee63.
If you need Microsoft Entra ID authentication in your ASP.NET Core API, write to me about your callers and the permissions you need to enforce.
Top comments (0)