Protecting APIs with IdentityServer
When to Use This Skill
- Configuring JWT bearer authentication in an ASP.NET Core API to validate tokens from IdentityServer
- Setting up reference token introspection with
AddOAuth2Introspection - Handling both JWT and reference tokens in the same API using
ForwardReferenceToken - Implementing scope-based authorization policies
- Validating Proof-of-Possession tokens (DPoP and mTLS
cnfclaim) - Protecting APIs hosted in the same application as IdentityServer (local API authentication)
- Securing multi-audience API deployments
Docs: https://docs.duendesoftware.com/identityserver/apis/
Core Concepts
APIs are the resources that IdentityServer protects. Clients obtain access tokens from IdentityServer, then present those tokens to APIs. The API must validate the token and enforce authorization based on the token's claims (scopes, audience, subject, etc.).
Token Formats at the API
JWT Bearer Authentication
Basic Setup
Install the standard Microsoft JWT bearer package:
Configure the authentication handler:
Critical: JWT Type Validation
Always set ValidTypes to ["at+jwt"] to protect against JWT confusion attacks. Without this, an attacker could present an identity token (which is also a JWT signed by the same issuer) to an API:
IdentityServer sets the typ header to at+jwt on all access token JWTs (per RFC 9068). This is controlled by IdentityServerOptions.AccessTokenJwtType.
Audience Validation
The Audience property on JwtBearerOptions validates the aud claim in the access token. The audience value comes from the ApiResource name in IdentityServer:
If Audience is not set, audience validation is skipped (not recommended for production).
Multi-Audience APIs
When an API belongs to multiple logical resources, configure multiple valid audiences:
Reference Token Introspection
For APIs that receive reference tokens (opaque strings rather than JWTs), use the OAuth 2.0 introspection package:
Or use the introspection handler directly:
The ClientId and ClientSecret correspond to the ApiResource name and secret configured in IdentityServer:
Common Pitfall: Missing ApiSecrets
Handling Both JWT and Reference Tokens
Use ForwardReferenceToken from the Duende.AspNetCore.Authentication.JwtBearer package to support both token formats in a single API. This selector inspects the token: if it contains a dot (.) it is treated as a JWT; otherwise it is forwarded to the introspection handler.
How ForwardReferenceToken Works
The selector checks whether the incoming Bearer token string contains a dot (.):
- Contains a dot → treated as a JWT, validated by
AddJwtBearer - No dot → treated as a reference token, forwarded to
AddOAuth2Introspection
This is a simple heuristic: JWTs always contain dots (header.payload.signature), while reference tokens are opaque identifiers.
Scope-Based Authorization
Scope Claim Format
IdentityServer can emit scopes in two formats, controlled by EmitScopesAsSpaceDelimitedStringInJwt:
Normalizing Scope Claims
When scopes are emitted as a space-delimited string, the scope claim appears as a single string value. To normalize it back to individual claims for easier policy checks, implement a custom IClaimsTransformation:
This transformation converts a space-delimited scope claim into individual scope claims, so authorization policies work consistently regardless of the format.
Defining Authorization Policies
Apply policies to endpoints:
Or with controllers:
Proof-of-Possession (PoP) Token Validation
Proof-of-Possession binds an access token to a specific client's cryptographic key, preventing token theft/replay. IdentityServer supports two PoP mechanisms: Mutual TLS (mTLS) and DPoP.
mTLS Confirmation (cnf Claim)
When mTLS is used, the access token contains a cnf claim with the SHA-256 thumbprint of the client certificate:
To validate at the API, confirm the cnf thumbprint matches the client certificate presented on the TLS connection:
DPoP Validation
DPoP (Demonstration of Proof-of-Possession) uses a separate proof JWT in the DPoP HTTP header. Use the Duende.AspNetCore.Authentication.JwtBearer package which provides built-in DPoP validation:
DPoP Validation Details
The ConfigureDPoPTokensForScheme extension is called on IServiceCollection, not inside the AddJwtBearer options lambda. It:
- Validates the
DPoPproof JWT in the request header - Confirms the
jkt(JWK thumbprint) in the access token'scnfclaim matches the proof key - Verifies the proof is bound to the correct HTTP method and URL
- Uses
IDistributedCachefor nonce/replay detection
Local API Authentication
When your API is hosted in the same application as IdentityServer, use local API authentication to avoid the overhead of a network call to the token endpoint:
What AddLocalApiAuthentication Configures
AddLocalApiAuthentication() sets up:
- An authentication handler named
IdentityServerAccessToken(available asIdentityServerConstants.LocalApi.AuthenticationScheme) - An authorization policy named
IdentityServerConstants.LocalApi.PolicyNamethat requires theIdentityServerApiscope
Requiring the IdentityServerApi Scope
Clients that access local APIs must include IdentityServerApi in their allowed scopes:
Protecting Local API Endpoints
Custom Claims Transformation for Local APIs
You can add custom claims from the user store when using local API authentication:
Complete Example: API with JWT, Reference Tokens, and Scope-Based Policies
Common Anti-Patterns
-
❌ Omitting
ValidTypes = ["at+jwt"]— allows JWT confusion attacks where identity tokens are accepted as access tokens -
✅ Always validate the
at+jwttype header -
❌ Using
AddOAuth2Introspectionwithout configuringApiSecretson theApiResource -
✅ Always set a shared secret between the API and the introspection endpoint
-
❌ Hardcoding scope checks against a space-delimited string without normalization
-
✅ Implement a custom
IClaimsTransformationto split space-delimited scope claims into individual claims -
❌ Configuring DPoP validation without registering
IDistributedCache -
✅ Always register a distributed cache implementation for DPoP replay detection
-
❌ Using local API authentication but forgetting to add
IdentityServerApito client scopes -
✅ Clients accessing local APIs must request the
IdentityServerApiscope
Common Pitfalls
-
Audience mismatch: The
AudienceinJwtBearerOptionsmust match theApiResourcename in IdentityServer. A mismatch causes401responses with no clear error message in the API logs. -
Introspection returns inactive: If introspection returns
active: false, check that theApiResourcesecret matches and the scopes are correctly associated with the resource. -
Scope claim format inconsistency: If IdentityServer emits scopes as a space-delimited string but your policies expect individual claims, authorization will fail silently. Implement a custom
IClaimsTransformationto normalize. -
ForwardReferenceToken with wrong scheme name: The scheme name passed to
ForwardReferenceToken()must exactly match the scheme name used inAddOAuth2Introspection(). -
DPoP nonce stale errors: DPoP nonces have a limited validity window. If the API returns
use_dpop_nonce, the client must retry with the new nonce from theDPoP-Nonceresponse header. -
Local API auth in separate host:
AddLocalApiAuthentication()only works when the API is co-hosted with IdentityServer. For separate API hosts, use JWT bearer or introspection. -
Missing scope normalization in production: During development, scopes may work because of the default array format. When
EmitScopesAsSpaceDelimitedStringInJwtis enabled (or changed), policies break without a customIClaimsTransformationto split the scope claim.


