← Duende SkillsCONTENT HISTORY

Update to Duende Skills

Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "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.",
  "included_files": [],
  "name": "identityserver-api-protection",
  "skill_md_contents": "---\nname: identityserver-api-protection\ndescription: \"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.\"\ninvocable: false\n---\n\n# Protecting APIs with IdentityServer\n\n## When to Use This Skill\n\n- Configuring JWT bearer authentication in an ASP.NET Core API to validate tokens from IdentityServer\n- Setting up reference token introspection with `AddOAuth2Introspection`\n- Handling both JWT and reference tokens in the same API using `ForwardReferenceToken`\n- Implementing scope-based authorization policies\n- Validating Proof-of-Possession tokens (DPoP and mTLS `cnf` claim)\n- Protecting APIs hosted in the same application as IdentityServer (local API authentication)\n- Securing multi-audience API deployments\n\nDocs: https://docs.duendesoftware.com/identityserver/apis/\n\n## Core Concepts\n\nAPIs 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.).\n\n### Token Formats at the API\n\n| Format         | Validation Method                          | Revocable              | Network Dependency                   |\n| -------------- | ------------------------------------------ | ---------------------- | ------------------------------------ |\n| JWT (`at+jwt`) | Signature verification using issuer's JWKS | No (expires naturally) | None at validation time              |\n| Reference      | Introspection endpoint call                | Yes (immediate)        | Requires IdentityServer availability |\n\n## JWT Bearer Authentication\n\n### Basic Setup\n\nInstall the standard Microsoft JWT bearer package:\n\n```bash\ndotnet add package Microsoft.AspNetCore.Authentication.JwtBearer\n```\n\nConfigure the authentication handler:\n\n```csharp\n// Program.cs\nbuilder.Services.AddAuthentication(\"Bearer\")\n    .AddJwtBearer(\"Bearer\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.Audience = \"api1\";\n\n        options.TokenValidationParameters.ValidTypes = [\"at+jwt\"];\n    });\n```\n\n### Critical: JWT Type Validation\n\nAlways 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:\n\n```csharp\n// ❌ WRONG: No type validation — vulnerable to JWT confusion\noptions.TokenValidationParameters = new TokenValidationParameters\n{\n    ValidateAudience = true\n};\n\n// ✅ CORRECT: Validate the at+jwt type header\noptions.TokenValidationParameters.ValidTypes = [\"at+jwt\"];\n```\n\nIdentityServer sets the `typ` header to `at+jwt` on all access token JWTs (per RFC 9068). This is controlled by `IdentityServerOptions.AccessTokenJwtType`.\n\n### Audience Validation\n\nThe `Audience` property on `JwtBearerOptions` validates the `aud` claim in the access token. The audience value comes from the `ApiResource` name in IdentityServer:\n\n```csharp\n// IdentityServer configuration\nvar apiResource = new ApiResource(\"api1\")\n{\n    Scopes = { \"api1.read\", \"api1.write\" }\n};\n\n// API configuration\noptions.Audience = \"api1\";\n```\n\nIf `Audience` is not set, audience validation is skipped (not recommended for production).\n\n### Multi-Audience APIs\n\nWhen an API belongs to multiple logical resources, configure multiple valid audiences:\n\n```csharp\noptions.TokenValidationParameters.ValidAudiences = [\"api1\", \"api2\"];\n```\n\n## Reference Token Introspection\n\nFor APIs that receive reference tokens (opaque strings rather than JWTs), use the OAuth 2.0 introspection package:\n\n```bash\ndotnet add package Duende.IdentityServer.AccessTokenValidation\n```\n\nOr use the introspection handler directly:\n\n```bash\ndotnet add package Duende.AspNetCore.Authentication.OAuth2Introspection\n```\n\n```csharp\n// Program.cs\nbuilder.Services.AddAuthentication(\"token\")\n    .AddOAuth2Introspection(\"token\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.ClientId = \"api1\";\n        options.ClientSecret = \"api1_secret\";\n    });\n```\n\nThe `ClientId` and `ClientSecret` correspond to the `ApiResource` name and secret configured in IdentityServer:\n\n```csharp\n// IdentityServer configuration\nvar apiResource = new ApiResource(\"api1\")\n{\n    ApiSecrets = { new Secret(\"api1_secret\".Sha256()) },\n    Scopes = { \"api1.read\" }\n};\n```\n\n### Common Pitfall: Missing ApiSecrets\n\n```csharp\n// ❌ WRONG: No secret configured — introspection will fail with 401\nvar apiResource = new ApiResource(\"api1\")\n{\n    Scopes = { \"api1.read\" }\n};\n\n// ✅ CORRECT: ApiSecrets required for introspection\nvar apiResource = new ApiResource(\"api1\")\n{\n    ApiSecrets = { new Secret(\"secret\".Sha256()) },\n    Scopes = { \"api1.read\" }\n};\n```\n\n## Handling Both JWT and Reference Tokens\n\nUse `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.\n\n```bash\ndotnet add package Duende.AspNetCore.Authentication.JwtBearer\n```\n\n```csharp\n// Program.cs\nbuilder.Services.AddAuthentication(\"token\")\n    .AddJwtBearer(\"token\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.Audience = \"api1\";\n        options.TokenValidationParameters.ValidTypes = [\"at+jwt\"];\n\n        // Forward reference tokens to the introspection handler\n        options.ForwardDefaultSelector =\n            Selector.ForwardReferenceToken(\"introspection\");\n    })\n    .AddOAuth2Introspection(\"introspection\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.ClientId = \"api1\";\n        options.ClientSecret = \"api1_secret\";\n    });\n```\n\n### How ForwardReferenceToken Works\n\nThe selector checks whether the incoming Bearer token string contains a dot (`.`):\n\n- **Contains a dot** → treated as a JWT, validated by `AddJwtBearer`\n- **No dot** → treated as a reference token, forwarded to `AddOAuth2Introspection`\n\nThis is a simple heuristic: JWTs always contain dots (header.payload.signature), while reference tokens are opaque identifiers.\n\n## Scope-Based Authorization\n\n### Scope Claim Format\n\nIdentityServer can emit scopes in two formats, controlled by `EmitScopesAsSpaceDelimitedStringInJwt`:\n\n| Setting           | Claim Format           | Example                                |\n| ----------------- | ---------------------- | -------------------------------------- |\n| `false` (default) | JSON array             | `\"scope\": [\"api1.read\", \"api1.write\"]` |\n| `true`            | Space-delimited string | `\"scope\": \"api1.read api1.write\"`      |\n\n### Normalizing Scope Claims\n\nWhen 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`:\n\n```csharp\n// Program.cs\nbuilder.Services.AddAuthentication(\"Bearer\")\n    .AddJwtBearer(\"Bearer\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.Audience = \"api1\";\n        options.TokenValidationParameters.ValidTypes = [\"at+jwt\"];\n    });\n\n// Register a custom claims transformation to split space-delimited scopes\nbuilder.Services.AddTransient<IClaimsTransformation, ScopeClaimsTransformation>();\n```\n\n```csharp\n// ScopeClaimsTransformation.cs\npublic class ScopeClaimsTransformation : IClaimsTransformation\n{\n    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)\n    {\n        var identity = (ClaimsIdentity)principal.Identity!;\n        var scopeClaim = identity.FindFirst(\"scope\");\n        if (scopeClaim != null && scopeClaim.Value.Contains(' '))\n        {\n            identity.RemoveClaim(scopeClaim);\n            foreach (var scope in scopeClaim.Value.Split(' '))\n            {\n                identity.AddClaim(new Claim(\"scope\", scope));\n            }\n        }\n        return Task.FromResult(principal);\n    }\n}\n```\n\nThis transformation converts a space-delimited `scope` claim into individual `scope` claims, so authorization policies work consistently regardless of the format.\n\n### Defining Authorization Policies\n\n```csharp\n// Program.cs\nbuilder.Services.AddAuthorization(options =>\n{\n    options.AddPolicy(\"read\", policy =>\n    {\n        policy.RequireAuthenticatedUser();\n        policy.RequireClaim(\"scope\", \"api1.read\");\n    });\n\n    options.AddPolicy(\"write\", policy =>\n    {\n        policy.RequireAuthenticatedUser();\n        policy.RequireClaim(\"scope\", \"api1.write\");\n    });\n});\n```\n\nApply policies to endpoints:\n\n```csharp\napp.MapGet(\"/data\", () => Results.Ok(data))\n    .RequireAuthorization(\"read\");\n\napp.MapPost(\"/data\", (DataModel model) => Results.Created())\n    .RequireAuthorization(\"write\");\n```\n\nOr with controllers:\n\n```csharp\n[Authorize(Policy = \"read\")]\n[ApiController]\n[Route(\"api/[controller]\")]\npublic class DataController : ControllerBase\n{\n    [HttpGet]\n    public IActionResult Get() => Ok(data);\n\n    [HttpPost]\n    [Authorize(Policy = \"write\")]\n    public IActionResult Post(DataModel model) => Created();\n}\n```\n\n## Proof-of-Possession (PoP) Token Validation\n\nProof-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.\n\n### mTLS Confirmation (cnf Claim)\n\nWhen mTLS is used, the access token contains a `cnf` claim with the SHA-256 thumbprint of the client certificate:\n\n```json\n{\n  \"cnf\": {\n    \"x5t#S256\": \"bBBDDeEFSS...\"\n  }\n}\n```\n\nTo validate at the API, confirm the `cnf` thumbprint matches the client certificate presented on the TLS connection:\n\n```csharp\n// Program.cs\nbuilder.Services.AddAuthentication(\"Bearer\")\n    .AddJwtBearer(\"Bearer\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.Audience = \"api1\";\n        options.TokenValidationParameters.ValidTypes = [\"at+jwt\"];\n\n        options.Events = new JwtBearerEvents\n        {\n            OnTokenValidated = context =>\n            {\n                var cnfClaim = context.Principal?.FindFirst(\"cnf\");\n                if (cnfClaim != null)\n                {\n                    var certificate = context.HttpContext.Connection.ClientCertificate;\n                    if (certificate == null)\n                    {\n                        context.Fail(\"Client certificate required for mTLS tokens\");\n                        return Task.CompletedTask;\n                    }\n\n                    var thumbprint = Base64UrlEncoder.Encode(\n                        certificate.GetCertHash(HashAlgorithmName.SHA256));\n\n                    var cnf = JsonDocument.Parse(cnfClaim.Value);\n                    var expectedThumbprint = cnf.RootElement\n                        .GetProperty(\"x5t#S256\").GetString();\n\n                    if (thumbprint != expectedThumbprint)\n                    {\n                        context.Fail(\"Certificate thumbprint does not match cnf claim\");\n                    }\n                }\n                return Task.CompletedTask;\n            }\n        };\n    });\n```\n\n### DPoP Validation\n\nDPoP (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:\n\n```bash\ndotnet add package Duende.AspNetCore.Authentication.JwtBearer\n```\n\n```csharp\n// Program.cs\nbuilder.Services.AddAuthentication(\"token\")\n    .AddJwtBearer(\"token\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.Audience = \"api1\";\n        options.TokenValidationParameters.ValidTypes = [\"at+jwt\"];\n    });\n\n// Configure DPoP on the service collection, NOT inside AddJwtBearer\nbuilder.Services.ConfigureDPoPTokensForScheme(\"token\");\n\n// DPoP replay detection requires a distributed cache\nbuilder.Services.AddDistributedMemoryCache();\n```\n\n#### DPoP Validation Details\n\nThe `ConfigureDPoPTokensForScheme` extension is called on `IServiceCollection`, **not** inside the `AddJwtBearer` options lambda. It:\n\n1. Validates the `DPoP` proof JWT in the request header\n2. Confirms the `jkt` (JWK thumbprint) in the access token's `cnf` claim matches the proof key\n3. Verifies the proof is bound to the correct HTTP method and URL\n4. Uses `IDistributedCache` for nonce/replay detection\n\n```csharp\n// ❌ WRONG: DPoP configured inside AddJwtBearer lambda — this is not valid\nbuilder.Services.AddAuthentication(\"token\")\n    .AddJwtBearer(\"token\", options =>\n    {\n        options.ConfigureDPoPTokensForScheme(\"token\"); // ← wrong location\n    });\n\n// ✅ CORRECT: ConfigureDPoPTokensForScheme on IServiceCollection, plus distributed cache\nbuilder.Services.AddDistributedMemoryCache(); // or Redis, SQL, etc.\nbuilder.Services.AddAuthentication(\"token\")\n    .AddJwtBearer(\"token\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.Audience = \"api1\";\n        options.TokenValidationParameters.ValidTypes = [\"at+jwt\"];\n    });\nbuilder.Services.ConfigureDPoPTokensForScheme(\"token\");\n```\n\n## Local API Authentication\n\nWhen 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:\n\n```csharp\n// Program.cs (in the IdentityServer host)\nbuilder.Services.AddIdentityServer()\n    .AddInMemoryClients(Config.Clients)\n    .AddInMemoryApiScopes(Config.ApiScopes);\n\nbuilder.Services.AddLocalApiAuthentication();\n```\n\n### What AddLocalApiAuthentication Configures\n\n`AddLocalApiAuthentication()` sets up:\n\n- An authentication handler named `IdentityServerAccessToken` (available as `IdentityServerConstants.LocalApi.AuthenticationScheme`)\n- An authorization policy named `IdentityServerConstants.LocalApi.PolicyName` that requires the `IdentityServerApi` scope\n\n### Requiring the IdentityServerApi Scope\n\nClients that access local APIs must include `IdentityServerApi` in their allowed scopes:\n\n```csharp\n// IdentityServer configuration\nvar client = new Client\n{\n    ClientId = \"local_client\",\n    AllowedScopes = { \"openid\", \"profile\", \"IdentityServerApi\" }\n};\n```\n\n### Protecting Local API Endpoints\n\n```csharp\n// Using the built-in policy\napp.MapGet(\"/local-api/data\", () => Results.Ok(data))\n    .RequireAuthorization(IdentityServerConstants.LocalApi.PolicyName);\n\n// Or with controllers\n[Authorize(Policy = IdentityServerConstants.LocalApi.PolicyName)]\n[ApiController]\n[Route(\"local-api/[controller]\")]\npublic class LocalDataController : ControllerBase\n{\n    [HttpGet]\n    public IActionResult Get() => Ok(data);\n}\n```\n\n### Custom Claims Transformation for Local APIs\n\nYou can add custom claims from the user store when using local API authentication:\n\n```csharp\nbuilder.Services.AddLocalApiAuthentication(principal =>\n{\n    principal.Identities.First().AddClaim(\n        new Claim(\"additional_claim\", \"value\"));\n    return Task.FromResult(principal);\n});\n```\n\n## Complete Example: API with JWT, Reference Tokens, and Scope-Based Policies\n\n```csharp\n// Program.cs\nusing Duende.AspNetCore.Authentication.JwtBearer;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddAuthentication(\"token\")\n    .AddJwtBearer(\"token\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.Audience = \"api1\";\n        options.TokenValidationParameters.ValidTypes = [\"at+jwt\"];\n\n        options.ForwardDefaultSelector =\n            Selector.ForwardReferenceToken(\"introspection\");\n    })\n    .AddOAuth2Introspection(\"introspection\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.ClientId = \"api1\";\n        options.ClientSecret = \"api1_secret\";\n    });\n\n// Custom claims transformation to normalize space-delimited scope claims\nbuilder.Services.AddTransient<IClaimsTransformation, ScopeClaimsTransformation>();\n\nbuilder.Services.AddAuthorization(options =>\n{\n    options.AddPolicy(\"read\", policy =>\n    {\n        policy.RequireAuthenticatedUser();\n        policy.RequireClaim(\"scope\", \"api1.read\");\n    });\n\n    options.AddPolicy(\"write\", policy =>\n    {\n        policy.RequireAuthenticatedUser();\n        policy.RequireClaim(\"scope\", \"api1.write\");\n    });\n});\n\nvar app = builder.Build();\n\napp.UseAuthentication();\napp.UseAuthorization();\n\napp.MapGet(\"/data\", () => Results.Ok(new { message = \"Protected data\" }))\n    .RequireAuthorization(\"read\");\n\napp.MapPost(\"/data\", (DataModel model) => Results.Created())\n    .RequireAuthorization(\"write\");\n\napp.Run();\n```\n\n## Common Anti-Patterns\n\n- ❌ Omitting `ValidTypes = [\"at+jwt\"]` — allows JWT confusion attacks where identity tokens are accepted as access tokens\n- ✅ Always validate the `at+jwt` type header\n\n- ❌ Using `AddOAuth2Introspection` without configuring `ApiSecrets` on the `ApiResource`\n- ✅ Always set a shared secret between the API and the introspection endpoint\n\n- ❌ Hardcoding scope checks against a space-delimited string without normalization\n- ✅ Implement a custom `IClaimsTransformation` to split space-delimited scope claims into individual claims\n\n- ❌ Configuring DPoP validation without registering `IDistributedCache`\n- ✅ Always register a distributed cache implementation for DPoP replay detection\n\n- ❌ Using local API authentication but forgetting to add `IdentityServerApi` to client scopes\n- ✅ Clients accessing local APIs must request the `IdentityServerApi` scope\n\n## Common Pitfalls\n\n1. **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.\n\n2. **Introspection returns inactive**: If introspection returns `active: false`, check that the `ApiResource` secret matches and the scopes are correctly associated with the resource.\n\n3. **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.\n\n4. **ForwardReferenceToken with wrong scheme name**: The scheme name passed to `ForwardReferenceToken()` must exactly match the scheme name used in `AddOAuth2Introspection()`.\n\n5. **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.\n\n6. **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.\n\n7. **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.\n"
}

SHA-256 of public snapshot: 39eee490dfe59746333e13dffa426d0333e8d47dc033d03d8fab98e939b706ee