Claude
Skills
Sign in
Back

identityserver-api-protection

Included with Lifetime
$97 forever

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.

Backend & APIs

What this skill does


# 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 `cnf` claim)
- Protecting APIs hosted in the same application as IdentityServer (local API authentication)
- Securing multi-audience API deployments

Docs: https://docs.duendesoftware.com/identityserver/tokens/api-protection

## 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

| 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 |

## JWT Bearer Authentication

### Basic Setup

Install the standard Microsoft JWT bearer package:

```bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
```

Configure the authentication handler:

```csharp
// Program.cs
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        options.Authority = "https://identity.example.com";
        options.Audience = "api1";

        options.TokenValidationParameters.ValidTypes = ["at+jwt"];
    });
```

### 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:

```csharp
// ❌ 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`.

### 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:

```csharp
// 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).

### Multi-Audience APIs

When an API belongs to multiple logical resources, configure multiple valid audiences:

```csharp
options.TokenValidationParameters.ValidAudiences = ["api1", "api2"];
```

## Reference Token Introspection

For APIs that receive reference tokens (opaque strings rather than JWTs), use the OAuth 2.0 introspection package:

```bash
dotnet add package Duende.IdentityServer.AccessTokenValidation
```

Or use the introspection handler directly:

```bash
dotnet add package Duende.AspNetCore.Authentication.OAuth2Introspection
```

```csharp
// 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:

```csharp
// IdentityServer configuration
var apiResource = new ApiResource("api1")
{
    ApiSecrets = { new Secret("api1_secret".Sha256()) },
    Scopes = { "api1.read" }
};
```

### Common Pitfall: Missing ApiSecrets

```csharp
// ❌ 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" }
};
```

## 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.

```bash
dotnet add package Duende.AspNetCore.Authentication.JwtBearer
```

```csharp
// 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";
    });
```

### 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`:

| Setting           | Claim Format           | Example                                |
| ----------------- | ---------------------- | -------------------------------------- |
| `false` (default) | JSON array             | `"scope": ["api1.read", "api1.write"]` |
| `true`            | Space-delimited string | `"scope": "api1.read api1.write"`      |

### 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`:

```csharp
// 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>();
```

```csharp
// 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` c

Related in Backend & APIs