.NET
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.
| 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 shows. Then register it:
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
name: billing-worker
namespace: billing
spec:
clientId: billing-worker
issuer: https://auth.example.com
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: 300kubectl apply -f billing-worker-client.yaml
kubectl -n billing get oidcclient billing-worker # PHASE Ready, reason RegisteredThere 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.RedisMicrosoft.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:
| 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
dotnet add package Duende.AccessTokenManagementDuende.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: 80807. 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:
| 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 stack the issuer is
https://localhost:8080. On Windows and macOS, mkcert -install is enough. On Linux,
.NET reads the OpenSSL trust store, so point it at the mkcert root:
SSL_CERT_FILE="$(mkcert -CAROOT)/rootCA.pem" dotnet runKeep 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:
go run ./cmd/nauthera-cli create client billing-worker \
--grant-types client_credentials \
--scopes orders:read,orders:writeIn 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 yet.
accessTokenTtlSeconds: 300keeps that window to five minutes. Duende.AccessTokenManagementcaches tokens together with their DPoP key. It keeps them in memory unless anIDistributedCacheis 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
/introspectfrom 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.