# Protect an ASP.NET Core API with DPoP tokens

> Protect an ASP.NET Core REST API with DPoP-bound access tokens from Nauthera, and call it from a .NET service that authenticates with private_key_jwt.

This guide protects an ASP.NET Core REST API with DPoP-bound access tokens from
Nauthera and calls it from a .NET service. You need a Nauthera issuer with its
[operator](https://www.nauthera.io/docs/v1/operator) running in the cluster, .NET 10, and Redis for the API's
proof replay cache. At the end, the API accepts only tokens bound to a key its caller
holds, and the caller authenticates with a private key instead of a secret. The main
limit: the API refuses bearer tokens, so every caller must be able to request DPoP-bound
tokens.

An ASP.NET Core API validates Nauthera access tokens with the JwtBearer authentication
handler. It reads the issuer's discovery document, caches the signing keys from its JWKS,
and checks every token locally: no call to Nauthera per request. The API accepts only
DPoP-bound tokens, and checks the DPoP proof on each request with
`Microsoft.IdentityModel.Dpop`.

The API is named `https://orders.example.com/`, serves `/orders`, and expects the scopes
`orders:read` and `orders:write`. Its caller, `billing-worker`, gets tokens with
`Duende.AccessTokenManagement`. The examples use minimal APIs on .NET 10. The
[overview](https://www.nauthera.io/docs/v1/guides#the-setup-these-guides-use) explains the setup and the threat
each part answers.

| Package | License | Used by |
|---|---|---|
| `Microsoft.AspNetCore.Authentication.JwtBearer` | MIT | the API |
| `Microsoft.IdentityModel.Dpop` | MIT | the API |
| `StackExchange.Redis` | MIT | the API, for the proof replay cache |
| `Duende.AccessTokenManagement` (4.2 or later) | Apache 2.0 | the caller |

`Duende.AspNetCore.Authentication.JwtBearer` adds DPoP to the JwtBearer handler in one
call, but it needs a paid Duende license in production, so this guide does not use it.
`Duende.AccessTokenManagement` is open source and needs no license.

## 1. Register the caller

The API itself needs no client. Each service that calls it does. `billing-worker`
authenticates with `private_key_jwt`: create its key and its Secret
`billing-worker-client-key` as the
[overview](https://www.nauthera.io/docs/v1/guides#keys-instead-of-secrets) shows. Then register it:

```yaml
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: billing-worker
  namespace: billing      # a namespace the organization admits
spec:
  clientId: billing-worker
  displayName: Billing worker
  grantTypes:
    - client_credentials
  scopes:
    - orders:read
    - orders:write
  tokenEndpointAuthMethod: private_key_jwt
  jwksUri: https://billing.example.com/.well-known/jwks.json
  tokenEndpointAuthSigningAlg: ES256
  accessTokenTtlSeconds: 300
```

```bash
kubectl apply -f billing-worker-client.yaml
kubectl -n billing get oidcclient billing-worker   # PHASE Ready, reason Registered
```

There is no `secret` block, so the operator writes no Secret. The worker serves its own
`jwksUri` (step 6). Deploy it, with its ingress, before it asks for its first token.

## 2. Add the packages to the API

```bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.IdentityModel.Dpop
dotnet add package Microsoft.IdentityModel.Protocols.OpenIdConnect
dotnet add package StackExchange.Redis
```

`Microsoft.IdentityModel.Dpop` brings a newer `Microsoft.IdentityModel.JsonWebTokens` than
the JwtBearer package references. Adding `Microsoft.IdentityModel.Protocols.OpenIdConnect`
at the same version as `Microsoft.IdentityModel.Dpop` keeps every `Microsoft.IdentityModel`
package on one version; mixed versions fail at run time.

## 3. Configure the API

```json
// appsettings.json
{
  "Nauthera": {
    "Issuer": "https://auth.example.com",
    "Audience": "https://orders.example.com/"
  },
  "Redis": "redis.orders.svc:6379"
}
```

In Kubernetes, set them with the environment variables `Nauthera__Issuer`,
`Nauthera__Audience` and `Redis`. For an organization other than the main one, the issuer
is `https://auth.example.com/orgs/acme`.

The audience is also the API's public URL. The proof's `htu` is checked against it, not
against the request's `Host` header.

## 4. Configure authentication and authorization

```csharp
// Program.cs
using System.Security.Claims;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Dpop;
using Microsoft.IdentityModel.JsonWebTokens;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);
var nauthera = builder.Configuration.GetSection("Nauthera");
var issuer = nauthera["Issuer"]!;
var audience = nauthera["Audience"]!;
var apiUrl = new Uri(audience);

builder.Services.AddSingleton<IConnectionMultiplexer>(
    ConnectionMultiplexer.Connect(builder.Configuration["Redis"]!));
builder.Services.AddSingleton<IJtiReplayCache, RedisJtiReplayCache>();

builder.Services
    .AddAuthentication("DPoP")
    .AddJwtBearer("DPoP", options =>
    {
        options.Authority = issuer;
        options.Audience = audience;
        options.RequireHttpsMetadata = true;
        options.MapInboundClaims = false;
        options.Challenge = "DPoP algs=\"ES256\"";

        var tokens = options.TokenValidationParameters;
        tokens.ValidIssuer = issuer;
        tokens.ValidTypes = ["at+jwt"];
        tokens.ValidAlgorithms = ["ES256"];
        tokens.RequireExpirationTime = true;
        tokens.ClockSkew = TimeSpan.FromSeconds(30);

        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                string? authorization = context.Request.Headers.Authorization;
                if (string.IsNullOrEmpty(authorization))
                {
                    return Task.CompletedTask;
                }
                if (!authorization.StartsWith("DPoP ", StringComparison.OrdinalIgnoreCase))
                {
                    context.Fail("the DPoP authorization scheme is required");
                    return Task.CompletedTask;
                }
                context.Token = authorization["DPoP ".Length..].Trim();
                return Task.CompletedTask;
            },
            OnTokenValidated = async context =>
            {
                var request = context.Request;
                var proofs = request.Headers["DPoP"];
                var cnf = context.Principal?.FindFirst("cnf")?.Value;
                var jkt = cnf is null ? null : JsonNode.Parse(cnf)?["jkt"]?.GetValue<string>();
                if (proofs.Count != 1 || jkt is null)
                {
                    context.Fail("a DPoP-bound token and exactly one DPoP proof are required");
                    return;
                }

                var result = await new DpopProofValidator().ValidateAsync(
                    proofs[0]!,
                    request.Method,
                    new Uri(apiUrl, (request.PathBase + request.Path).ToUriComponent()),
                    ((JsonWebToken)context.SecurityToken).EncodedToken,
                    jkt,
                    new DpopValidationOptions
                    {
                        AllowedSigningAlgorithms = new HashSet<string> { "ES256" },
                        MaxLifetimeInSeconds = 60,
                        ClockSkewInSeconds = 30,
                        JtiReplayCache = context.HttpContext.RequestServices
                            .GetRequiredService<IJtiReplayCache>(),
                    },
                    context.HttpContext.RequestAborted);
                if (!result.IsValid)
                {
                    context.Fail(result.Error!.Message);
                }
            },
        };
    });

builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("orders:read", p => p.RequireAssertion(c => HasScope(c.User, "orders:read")))
    .AddPolicy("orders:write", p => p.RequireAssertion(c => HasScope(c.User, "orders:write")));

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/orders", (ClaimsPrincipal user) => new
{
    Subject = user.FindFirstValue("sub"),     // the client, for client_credentials
    Client = user.FindFirstValue("client_id"), // the application that called
}).RequireAuthorization("orders:read");

app.MapPost("/orders", () => Results.Created()).RequireAuthorization("orders:write");

app.Run();

// The scope claim is one space-separated string (RFC 9068 §2.2.3).
static bool HasScope(ClaimsPrincipal user, string scope) =>
    user.FindAll("scope").SelectMany(c => c.Value.Split(' ')).Contains(scope);

// SET NX is atomic, so two replicas cannot both accept the same proof.
sealed class RedisJtiReplayCache(IConnectionMultiplexer redis) : IJtiReplayCache
{
    public Task<bool> TryAddAsync(string jti, DateTimeOffset expiration, CancellationToken cancellationToken = default)
    {
        var ttl = expiration - DateTimeOffset.UtcNow;
        if (ttl < TimeSpan.FromSeconds(1))
        {
            ttl = TimeSpan.FromSeconds(1);
        }
        return redis.GetDatabase().StringSetAsync($"dpop-jti:{jti}", 1, ttl, When.NotExists);
    }
}
```

What each setting checks:

| Check | Where |
|---|---|
| Signature against the issuer's JWKS | `Authority`, which reads `{issuer}/.well-known/openid-configuration` and refuses an `http` issuer (`RequireHttpsMetadata`) |
| Only ES256 | `ValidAlgorithms` |
| Header `typ` is `at+jwt`, so an ID token is not accepted as an access token | `ValidTypes` |
| `iss` is the issuer | `ValidIssuer` |
| `aud` contains this API | `Audience` |
| `exp` is present and `exp` and `nbf` hold, with 30 seconds of clock skew | `RequireExpirationTime`, `ClockSkew` |
| The request uses `Authorization: DPoP`, never `Bearer` | `OnMessageReceived` |
| The token carries `cnf.jkt` and the request carries exactly one proof | `OnTokenValidated` |
| The proof: `typ` is `dpop+jwt`, ES256, a public `jwk` and a valid signature, `htm` and `htu` match the request, `iat` within 60 seconds (plus 30 of skew), `ath` is the token's hash, and the key's RFC 7638 thumbprint is `cnf.jkt` | `DpopProofValidator` (RFC 9449 §4.3) |
| The proof is not replayed | the `jti` cache in Redis, kept until the proof's `iat` window has passed |
| The scope the endpoint needs | the `orders:*` policies; every other endpoint needs an authenticated caller (`SetFallbackPolicy`) |

The handler caches the discovery document and keys, and fetches them again when a token
names a key id it does not hold, so a key rotation needs no restart.

`MapInboundClaims = false` keeps `sub`, `scope` and `cnf` under their own names. Without
it, `sub` becomes `ClaimTypes.NameIdentifier` and `scope` becomes
`http://schemas.microsoft.com/identity/claims/scope`.

A failure answers `401` with `WWW-Authenticate: DPoP algs="ES256", error="invalid_token"`,
as RFC 9449 §7.1 describes. The validator fails closed: if Redis cannot be reached, the
request is refused. The API does not issue its own DPoP nonces; replay protection comes
from the `jti` cache and the short `iat` window.

## 5. Add the packages to the caller

```bash
dotnet add package Duende.AccessTokenManagement
```

`Duende.AccessTokenManagement` requests a token with `client_credentials`, caches it until
shortly before it expires, and adds `Authorization: DPoP` and a fresh proof with `ath` to
every request through its `HttpClient`. When `/token` answers `use_dpop_nonce`, it retries
once with the nonce from the `DPoP-Nonce` header, a new proof and a new client assertion.
Nauthera authenticates the client before it checks the proof, so the refused first request
has already used up its assertion's `jti`. Use version 4.2 or later: earlier versions resend
that assertion and the retry is refused.

## 6. Configure the caller

```json
// appsettings.json
{
  "Nauthera": {
    "Issuer": "https://auth.example.com",
    "ClientId": "billing-worker",
    "ClientKeyPath": "/var/run/secrets/nauthera/client-key.pem"
  }
}
```

Mount the Secret at that path:

```yaml
spec:
  volumes:
    - name: client-key
      secret:
        secretName: billing-worker-client-key
  containers:
    - name: billing-worker
      volumeMounts:
        - name: client-key
          mountPath: /var/run/secrets/nauthera
          readOnly: true
```

```csharp
// Program.cs
using System.Security.Cryptography;
using System.Text.Json;
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.DPoP;
using Duende.IdentityModel;
using Duende.IdentityModel.Client;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);
var nauthera = builder.Configuration.GetSection("Nauthera");
var issuer = nauthera["Issuer"]!;
var clientId = nauthera["ClientId"]!;

var clientKey = ECDsa.Create();
clientKey.ImportFromPem(File.ReadAllText(nauthera["ClientKeyPath"]!));
var signingKey = new ECDsaSecurityKey(clientKey);
signingKey.KeyId = Base64UrlEncoder.Encode(
    JsonWebKeyConverter.ConvertFromECDsaSecurityKey(signingKey).ComputeJwkThumbprint());

builder.Services.AddSingleton<IClientAssertionService>(
    new PrivateKeyJwtAssertion(issuer, clientId, signingKey));

builder.Services.AddClientCredentialsTokenManagement()
    .AddClient("orders", client =>
    {
        client.TokenEndpoint = new Uri($"{issuer}/token");
        client.ClientId = ClientId.Parse(clientId);
        client.Scope = Scope.Parse("orders:read orders:write");
        client.Resource = Resource.Parse("https://orders.example.com/");
        client.DPoPJsonWebKey = NewDPoPKey();
    });

builder.Services.AddClientCredentialsHttpClient(
    "orders",
    ClientCredentialsClientName.Parse("orders"),
    http => http.BaseAddress = new Uri("https://orders.example.com/"));

var app = builder.Build();

app.MapGet("/.well-known/jwks.json", () =>
{
    var p = clientKey.ExportParameters(includePrivateParameters: false);
    return Results.Json(new
    {
        keys = new[]
        {
            new
            {
                kty = "EC", crv = "P-256", use = "sig", alg = "ES256", kid = signingKey.KeyId,
                x = Base64UrlEncoder.Encode(p.Q.X!), y = Base64UrlEncoder.Encode(p.Q.Y!),
            },
        },
    });
});

app.Run();

// The DPoP key lives only in memory: a restarted pod gets a new key and a new token.
static DPoPProofKey NewDPoPKey()
{
    var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(
        new ECDsaSecurityKey(ECDsa.Create(ECCurve.NamedCurves.nistP256)));
    jwk.Alg = SecurityAlgorithms.EcdsaSha256;
    return DPoPProofKey.Parse(JsonSerializer.Serialize(jwk));
}

// RFC 7523 §3: iss and sub are the client, aud names the authorization server, and the
// jti is new for each assertion because Nauthera accepts it once.
sealed class PrivateKeyJwtAssertion(string issuer, string clientId, SecurityKey key)
    : IClientAssertionService
{
    private readonly JsonWebTokenHandler handler = new();
    private readonly SigningCredentials credentials = new(key, SecurityAlgorithms.EcdsaSha256);

    public Task<ClientAssertion?> GetClientAssertionAsync(
        ClientCredentialsClientName? clientName = null,
        TokenRequestParameters? parameters = null,
        CancellationToken ct = default)
    {
        var now = DateTime.UtcNow;
        var assertion = handler.CreateToken(new SecurityTokenDescriptor
        {
            TokenType = "client-authentication+jwt",
            Issuer = clientId,
            Audience = issuer,
            IssuedAt = now,
            NotBefore = now,
            Expires = now.AddSeconds(60),
            Claims = new Dictionary<string, object>
            {
                ["sub"] = clientId,
                ["jti"] = Guid.NewGuid().ToString(),
            },
            SigningCredentials = credentials,
        });
        return Task.FromResult<ClientAssertion?>(new ClientAssertion
        {
            Type = OidcConstants.ClientAssertionTypes.JwtBearer,
            Value = assertion,
        });
    }
}
```

The assertion's `aud` is the issuer, which Nauthera accepts, as it does `{issuer}/token`.
It must be ES256 here, because the client registered `tokenEndpointAuthSigningAlg: ES256`.
The `kid` is the key's RFC 7638 thumbprint, so it changes when the key does.

Expose only the JWK set through the ingress, so Nauthera can fetch it at the registered
`jwksUri`:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: billing-worker-jwks
  namespace: billing
spec:
  tls:
    - hosts: [billing.example.com]
      secretName: billing-example-com-tls
  rules:
    - host: billing.example.com
      http:
        paths:
          - path: /.well-known/jwks.json
            pathType: Exact
            backend:
              service:
                name: billing-worker
                port:
                  number: 8080
```

## 7. Call the API

Use the named client anywhere in the worker:

```csharp
var orders = httpClientFactory.CreateClient("orders");
var response = await orders.GetAsync("orders");
```

What the API answers:

| Request | Response |
|---|---|
| A DPoP-bound token with `orders:read`, sent as `DPoP` with a valid proof | `200` |
| The same token sent as `Authorization: Bearer` | `401` |
| No `DPoP` header, two of them, or a token without `cnf.jkt` | `401` |
| A proof for another URL or method, another token, another key, or one already used | `401` |
| A token requested without `resource` | `401`, `error_description="The audience … is invalid"` |
| A token without `orders:read` | `403` |

## Local development

Against the [Quick Start](https://www.nauthera.io/docs/v1/quick-start) stack the issuer is
`https://localhost:8443`, and its certificate comes from a CA the chart created:
`nauthera-local-ca.crt`, which [step 1](https://www.nauthera.io/docs/v1/quick-start#1-start-the-stack) saves.
On Linux, .NET reads the OpenSSL trust store, so point it at that file:

```bash
SSL_CERT_FILE="$PWD/nauthera-local-ca.crt" dotnet run
```

On Windows and macOS, add it to the system trust store instead; `helm get notes
nauthera -n nauthera` shows how, and how to remove it again.

Keep `RequireHttpsMetadata` on: the Quick Start issuer is served over `https`.

Nauthera cannot fetch a `jwksUri` on your machine, because loopback and private addresses
are refused. Locally, register the caller with a generated secret and keep DPoP, with an
`OIDCClient` as in [Quick Start step 4](https://www.nauthera.io/docs/v1/quick-start#4-register-your-own-client):

```yaml
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: billing-worker
  namespace: nauthera
spec:
  clientId: billing-worker
  grantTypes:
    - client_credentials
  scopes:
    - orders:read
    - orders:write
  tokenEndpointAuthMethod: client_secret_basic
  secret:
    generate: true
```

```bash
kubectl apply -f billing-worker.yaml
kubectl -n nauthera get secret billing-worker -o jsonpath='{.data.client-secret}' | base64 -d
```

In the caller, remove the `IClientAssertionService` registration and the JWK set route, and
authenticate with the secret instead (`client_secret_basic`):

```csharp
client.ClientSecret = ClientSecret.Parse(nauthera["ClientSecret"]!);
client.ClientCredentialStyle = ClientCredentialStyle.AuthorizationHeader;
```

Name the API by its local URL, for example `https://localhost:7043/`, both in the API's
`Nauthera:Audience` and in the caller's `Resource`, because the API checks each proof's
`htu` against it. Run Redis for the replay cache with `docker run --rm -p 6379:6379 redis`
and set `Redis` to `localhost:6379`.

## Notes

- A revoked or logged-out access token stays valid until it expires, because revocation
  does not reach access tokens. `accessTokenTtlSeconds: 300` keeps that window to five
  minutes.
- `Duende.AccessTokenManagement` caches tokens together with their DPoP key. It keeps them
  in memory unless an `IDistributedCache` is registered in the caller; registering one
  writes the DPoP private key to that cache.
- Every API replica must share the Redis instance, or a proof replayed to another replica
  is accepted.
- Do not use `/introspect` from the API: only the client a token was issued to can
  introspect it. See
  [Connect an Application](https://www.nauthera.io/docs/v1/integration#9-validate-access-tokens-in-your-api).
- DPoP at Nauthera's own endpoints, nonces included, is described in
  [Tokens & Sender Constraint](https://www.nauthera.io/docs/v1/tokens#dpop-sender-constrained-tokens).
