Help us improve
Share bugs, ideas, or general feedback.
From duende-skills
Protecting APIs with Duende IdentityServer: JWT bearer authentication, reference token introspection, scope-based authorization, DPoP/mTLS proof-of-possession validation, local API authentication, and multi-audience scenarios.
npx claudepluginhub duendesoftware/duende-skills --plugin duende-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/duende-skills:identityserver-api-protectionThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- Configuring JWT bearer authentication in an ASP.NET Core API to validate tokens from IdentityServer
Secures ASP.NET Core Web API endpoints with JWT Bearer token validation and Auth0 integration. Handles DPoP proof-of-possession binding.
Implementing API auth. Identity, OAuth/OIDC, JWT bearer, passkeys (WebAuthn), CORS, rate limiting.
Guides ASP.NET Core authentication and authorization with JWT bearer tokens, OpenID Connect, ASP.NET Identity, policies, roles, claims, and API keys. For login, endpoint protection, and auth rules.
Share bugs, ideas, or general feedback.
AddOAuth2IntrospectionForwardReferenceTokencnf claim)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.).
| Format | Validation Method | Revocable | Network Dependency |
|---|---|---|---|
JWT (at+jwt) | Signature verification using issuer's JWKS | No (expires naturally) | None at validation time |
| Reference | Introspection endpoint call | Yes (immediate) | Requires IdentityServer availability |
Install the standard Microsoft JWT bearer package:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Configure the authentication handler:
// Program.cs
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
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:
// ❌ WRONG: No type validation — vulnerable to JWT confusion
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true
};
// ✅ CORRECT: Validate the at+jwt type header
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
IdentityServer sets the typ header to at+jwt on all access token JWTs (per RFC 9068). This is controlled by IdentityServerOptions.AccessTokenJwtType.
The Audience property on JwtBearerOptions validates the aud claim in the access token. The audience value comes from the ApiResource name in IdentityServer:
// IdentityServer configuration
var apiResource = new ApiResource("api1")
{
Scopes = { "api1.read", "api1.write" }
};
// API configuration
options.Audience = "api1";
If Audience is not set, audience validation is skipped (not recommended for production).
When an API belongs to multiple logical resources, configure multiple valid audiences:
options.TokenValidationParameters.ValidAudiences = ["api1", "api2"];
For APIs that receive reference tokens (opaque strings rather than JWTs), use the OAuth 2.0 introspection package:
dotnet add package Duende.IdentityServer.AccessTokenValidation
Or use the introspection handler directly:
dotnet add package Duende.AspNetCore.Authentication.OAuth2Introspection
// Program.cs
builder.Services.AddAuthentication("token")
.AddOAuth2Introspection("token", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "api1";
options.ClientSecret = "api1_secret";
});
The ClientId and ClientSecret correspond to the ApiResource name and secret configured in IdentityServer:
// IdentityServer configuration
var apiResource = new ApiResource("api1")
{
ApiSecrets = { new Secret("api1_secret".Sha256()) },
Scopes = { "api1.read" }
};
// ❌ WRONG: No secret configured — introspection will fail with 401
var apiResource = new ApiResource("api1")
{
Scopes = { "api1.read" }
};
// ✅ CORRECT: ApiSecrets required for introspection
var apiResource = new ApiResource("api1")
{
ApiSecrets = { new Secret("secret".Sha256()) },
Scopes = { "api1.read" }
};
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.
dotnet add package Duende.AspNetCore.Authentication.JwtBearer
// Program.cs
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
// Forward reference tokens to the introspection handler
options.ForwardDefaultSelector =
Selector.ForwardReferenceToken("introspection");
})
.AddOAuth2Introspection("introspection", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "api1";
options.ClientSecret = "api1_secret";
});
The selector checks whether the incoming Bearer token string contains a dot (.):
AddJwtBearerAddOAuth2IntrospectionThis is a simple heuristic: JWTs always contain dots (header.payload.signature), while reference tokens are opaque identifiers.
IdentityServer can emit scopes in two formats, controlled by EmitScopesAsSpaceDelimitedStringInJwt:
| Setting | Claim Format | Example |
|---|---|---|
false (default) | JSON array | "scope": ["api1.read", "api1.write"] |
true | Space-delimited string | "scope": "api1.read api1.write" |
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:
// Program.cs
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
// Register a custom claims transformation to split space-delimited scopes
builder.Services.AddTransient<IClaimsTransformation, ScopeClaimsTransformation>();
// ScopeClaimsTransformation.cs
public class ScopeClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity!;
var scopeClaim = identity.FindFirst("scope");
if (scopeClaim != null && scopeClaim.Value.Contains(' '))
{
identity.RemoveClaim(scopeClaim);
foreach (var scope in scopeClaim.Value.Split(' '))
{
identity.AddClaim(new Claim("scope", scope));
}
}
return Task.FromResult(principal);
}
}
This transformation converts a space-delimited scope claim into individual scope claims, so authorization policies work consistently regardless of the format.
// Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1.read");
});
options.AddPolicy("write", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1.write");
});
});
Apply policies to endpoints:
app.MapGet("/data", () => Results.Ok(data))
.RequireAuthorization("read");
app.MapPost("/data", (DataModel model) => Results.Created())
.RequireAuthorization("write");
Or with controllers:
[Authorize(Policy = "read")]
[ApiController]
[Route("api/[controller]")]
public class DataController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(data);
[HttpPost]
[Authorize(Policy = "write")]
public IActionResult Post(DataModel model) => Created();
}
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.
When mTLS is used, the access token contains a cnf claim with the SHA-256 thumbprint of the client certificate:
{
"cnf": {
"x5t#S256": "bBBDDeEFSS..."
}
}
To validate at the API, confirm the cnf thumbprint matches the client certificate presented on the TLS connection:
// Program.cs
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
var cnfClaim = context.Principal?.FindFirst("cnf");
if (cnfClaim != null)
{
var certificate = context.HttpContext.Connection.ClientCertificate;
if (certificate == null)
{
context.Fail("Client certificate required for mTLS tokens");
return Task.CompletedTask;
}
var thumbprint = Base64UrlEncoder.Encode(
certificate.GetCertHash(HashAlgorithmName.SHA256));
var cnf = JsonDocument.Parse(cnfClaim.Value);
var expectedThumbprint = cnf.RootElement
.GetProperty("x5t#S256").GetString();
if (thumbprint != expectedThumbprint)
{
context.Fail("Certificate thumbprint does not match cnf claim");
}
}
return Task.CompletedTask;
}
};
});
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:
dotnet add package Duende.AspNetCore.Authentication.JwtBearer
// Program.cs
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
// Configure DPoP on the service collection, NOT inside AddJwtBearer
builder.Services.ConfigureDPoPTokensForScheme("token");
// DPoP replay detection requires a distributed cache
builder.Services.AddDistributedMemoryCache();
The ConfigureDPoPTokensForScheme extension is called on IServiceCollection, not inside the AddJwtBearer options lambda. It:
DPoP proof JWT in the request headerjkt (JWK thumbprint) in the access token's cnf claim matches the proof keyIDistributedCache for nonce/replay detection// ❌ WRONG: DPoP configured inside AddJwtBearer lambda — this is not valid
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.ConfigureDPoPTokensForScheme("token"); // ← wrong location
});
// ✅ CORRECT: ConfigureDPoPTokensForScheme on IServiceCollection, plus distributed cache
builder.Services.AddDistributedMemoryCache(); // or Redis, SQL, etc.
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
builder.Services.ConfigureDPoPTokensForScheme("token");
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:
// Program.cs (in the IdentityServer host)
builder.Services.AddIdentityServer()
.AddInMemoryClients(Config.Clients)
.AddInMemoryApiScopes(Config.ApiScopes);
builder.Services.AddLocalApiAuthentication();
AddLocalApiAuthentication() sets up:
IdentityServerAccessToken (available as IdentityServerConstants.LocalApi.AuthenticationScheme)IdentityServerConstants.LocalApi.PolicyName that requires the IdentityServerApi scopeClients that access local APIs must include IdentityServerApi in their allowed scopes:
// IdentityServer configuration
var client = new Client
{
ClientId = "local_client",
AllowedScopes = { "openid", "profile", "IdentityServerApi" }
};
// Using the built-in policy
app.MapGet("/local-api/data", () => Results.Ok(data))
.RequireAuthorization(IdentityServerConstants.LocalApi.PolicyName);
// Or with controllers
[Authorize(Policy = IdentityServerConstants.LocalApi.PolicyName)]
[ApiController]
[Route("local-api/[controller]")]
public class LocalDataController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(data);
}
You can add custom claims from the user store when using local API authentication:
builder.Services.AddLocalApiAuthentication(principal =>
{
principal.Identities.First().AddClaim(
new Claim("additional_claim", "value"));
return Task.FromResult(principal);
});
// Program.cs
using Duende.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
options.ForwardDefaultSelector =
Selector.ForwardReferenceToken("introspection");
})
.AddOAuth2Introspection("introspection", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "api1";
options.ClientSecret = "api1_secret";
});
// Custom claims transformation to normalize space-delimited scope claims
builder.Services.AddTransient<IClaimsTransformation, ScopeClaimsTransformation>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1.read");
});
options.AddPolicy("write", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1.write");
});
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/data", () => Results.Ok(new { message = "Protected data" }))
.RequireAuthorization("read");
app.MapPost("/data", (DataModel model) => Results.Created())
.RequireAuthorization("write");
app.Run();
❌ Omitting ValidTypes = ["at+jwt"] — allows JWT confusion attacks where identity tokens are accepted as access tokens
✅ Always validate the at+jwt type header
❌ Using AddOAuth2Introspection without configuring ApiSecrets on the ApiResource
✅ 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 IClaimsTransformation to 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 IdentityServerApi to client scopes
✅ Clients accessing local APIs must request the IdentityServerApi scope
Audience mismatch: The Audience in JwtBearerOptions must match the ApiResource name in IdentityServer. A mismatch causes 401 responses with no clear error message in the API logs.
Introspection returns inactive: If introspection returns active: false, check that the ApiResource secret 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 IClaimsTransformation to normalize.
ForwardReferenceToken with wrong scheme name: The scheme name passed to ForwardReferenceToken() must exactly match the scheme name used in AddOAuth2Introspection().
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 the DPoP-Nonce response 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 EmitScopesAsSpaceDelimitedStringInJwt is enabled (or changed), policies break without a custom IClaimsTransformation to split the scope claim.