Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

Protect an ASP.NET Core API with DPoP tokens

Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated

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 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 explains the setup and the threat each part answers.

PackageLicenseUsed by
Microsoft.AspNetCore.Authentication.JwtBearerMITthe API
Microsoft.IdentityModel.DpopMITthe API
StackExchange.RedisMITthe API, for the proof replay cache
Duende.AccessTokenManagement (4.2 or later)Apache 2.0the 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 shows. Then register it:

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

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

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

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

CheckWhere
Signature against the issuer's JWKSAuthority, which reads {issuer}/.well-known/openid-configuration and refuses an http issuer (RequireHttpsMetadata)
Only ES256ValidAlgorithms
Header typ is at+jwt, so an ID token is not accepted as an access tokenValidTypes
iss is the issuerValidIssuer
aud contains this APIAudience
exp is present and exp and nbf hold, with 30 seconds of clock skewRequireExpirationTime, ClockSkew
The request uses Authorization: DPoP, never BearerOnMessageReceived
The token carries cnf.jkt and the request carries exactly one proofOnTokenValidated
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.jktDpopProofValidator (RFC 9449 §4.3)
The proof is not replayedthe jti cache in Redis, kept until the proof's iat window has passed
The scope the endpoint needsthe 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

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

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

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

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:

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

What the API answers:

RequestResponse
A DPoP-bound token with orders:read, sent as DPoP with a valid proof200
The same token sent as Authorization: Bearer401
No DPoP header, two of them, or a token without cnf.jkt401
A proof for another URL or method, another token, another key, or one already used401
A token requested without resource401, error_description="The audience … is invalid"
A token without orders:read403

Local development

Against the 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 saves. On Linux, .NET reads the OpenSSL trust store, so point it at that file:

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:

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

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.
  • DPoP at Nauthera's own endpoints, nonces included, is described in Tokens & Sender Constraint.