Bearer tokens have one big weakness. Whoever holds the token can use it, no questions asked. If an access token leaks through a compromised proxy, a badly configured log file, or a browser extension reading local storage, an attacker can replay it against your API from anywhere and your API has no way of knowing the caller is not the real client. OAuth 2.0 DPoP, short for Demonstrating Proof-of-Possession, fixes this by binding the access token to a private key that only the legitimate client holds. This article walks through a working ASP.NET Core implementation built on Duende IdentityServer, based on Damien Bod’s original write-up, and adds some practical notes on where DPoP still has rough edges in production.
At the time this was written, DPoP was still a draft standard (draft-ietf-oauth-dpop), and it has since been finalized as RFC 9449. Client and server library support was patchy in 2023, so if you are building this today, check your OpenID Connect library’s changelog before assuming the API shown here matches exactly. The concepts do not change though, and that is the part worth understanding well.
Why sender-constrained tokens matter
A plain bearer access token is a bit like a taxi voucher with no name on it. Anyone holding the voucher gets the ride. DPoP turns that voucher into something closer to a boarding pass with your passport details on it. The token is cryptographically tied to a key pair generated by the client, and every API request must carry a fresh proof, signed with that private key, showing the caller actually owns the key referenced in the token.
This matters most for token replay scenarios: leaked logs, SSRF bugs, misconfigured CDNs caching an Authorization header, or a malicious browser extension. With a normal bearer token, a copied token is a fully usable token. With DPoP, a copied access token is useless without the matching private key, which never leaves the client that generated it.
Solution setup
The sample uses three separate applications, which is the realistic shape of most enterprise OAuth deployments. The first is an OpenID Connect and OAuth server built with Duende IdentityServer and ASP.NET Core Identity, responsible for issuing tokens. The second is an ASP.NET Core Razor Pages web client, set up as a confidential client using the authorization code flow with PKCE, which requests DPoP-bound access tokens. The third is a protected Web API built with ASP.NET Core and Swagger, which only accepts access tokens that pass DPoP validation, including the correct proof token and a matching cnf claim.

Keeping these three roles separate is not just a demo convenience. In production, your identity provider, your client applications, and your APIs are usually owned by different teams, sometimes different vendors. Understanding which side is responsible for which part of the DPoP handshake helps when things break, and something will break the first time you wire this up.
How DPoP actually works
The mechanics are simpler than the specification reads. The client generates an asymmetric key pair, typically ECDSA, and keeps the private key local. For every request that needs authentication, the client creates a small signed JWT called the DPoP proof, containing the HTTP method, the target URL, a timestamp, and a unique identifier to prevent replay. This proof travels in a DPoP header alongside the Authorization header carrying the access token.

The access token itself contains a cnf (confirmation) claim with a thumbprint of the client’s public key. When the API receives a request, it checks three things: the access token is valid as usual, the DPoP proof is correctly signed and fresh, and the key thumbprint in the proof matches the cnf claim in the token. If any of these three do not line up, the request is rejected. This is what makes a stolen token worthless on its own.
Configuring the Duende IdentityServer client
On the identity server side, there is very little extra configuration needed for DPoP itself. The API scope and the client registration look like a completely standard confidential client using authorization code flow with PKCE. Duende IdentityServer handles the DPoP-specific validation once the client starts sending proof tokens, so you are not hand-rolling any cryptography at this layer.
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("scope-dpop")
};
public static IEnumerable<Client> Clients =>
new Client[]
{
new Client
{
ClientId = "web-dpop",
ClientSecrets = { new Secret("--secret--".Sha256()) },
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
RedirectUris = {
"https://localhost:5007/signin-oidc"
},
FrontChannelLogoutUri = "https://localhost:5007/signout-oidc",
PostLogoutRedirectUris = {
"https://localhost:5007/signout-callback-oidc"
},
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "scope-dpop" }
}
};
Nothing here mentions DPoP explicitly. The client registration is deliberately ordinary, because the DPoP behaviour is switched on later, from the client application’s own configuration when it requests tokens. This keeps the identity server configuration reusable across clients that use DPoP and clients that still use plain bearer tokens, which is handy if you are migrating gradually rather than flipping every client over at once.
Setting up the web app client
The web client is where the actual key generation and proof creation happens. It creates an ECDSA key pair, in this case using a P-384 curve, and persists it as PEM files so the same key survives application restarts. On a mobile or desktop native client, you would generate this key once at install time and store it in the platform’s secure storage instead of a file on disk.
services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = false;
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://localhost:5001";
options.ClientId = "web-dpop";
options.ClientSecret = "--secret--";
options.ResponseType = "code";
options.ResponseMode = "query";
options.UsePkce = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("scope-dpop");
options.Scope.Add("offline_access");
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
});
var privatePem = File.ReadAllText(Path.Combine(_env.ContentRootPath,
"ecdsa384-private.pem"));
var publicPem = File.ReadAllText(Path.Combine(_env.ContentRootPath,
"ecdsa384-public.pem"));
var ecdsaCertificate = X509Certificate2.CreateFromPem(publicPem, privatePem);
var ecdsaCertificateKey = new ECDsaSecurityKey(ecdsaCertificate.GetECDsaPrivateKey());
services.AddOpenIdConnectAccessTokenManagement(options =>
{
var jwk = JsonWebKeyConverter.ConvertFromSecurityKey(ecdsaCertificateKey);
jwk.Alg = "ES384";
options.DPoPJsonWebKey = JsonSerializer.Serialize(jwk);
});
services.AddUserAccessTokenHttpClient(
"dpop-api-client", configureClient: client =>
{
client.BaseAddress = new Uri("https://localhost:5005");
});
services.AddRazorPages();
The important line is DPoPJsonWebKey. Once this is set on the access token management options, the Duende client library automatically creates and attaches a fresh DPoP proof to every outgoing request made through the registered HttpClient, and it also rebinds refresh token requests to the same key. You do not have to build proof tokens by hand anywhere in application code, which is exactly how this should work for a security primitive: the plumbing should be invisible until something goes wrong and you need to debug it.
A common mistake at this stage is reusing the same private key across multiple client instances or environments. If a compromised key gets shared between your staging and production web apps, you lose the isolation DPoP is supposed to give you. Treat this key with the same care as a client secret, not as a static asset you commit to source control.
Implementing the API side
The API needs to validate three things per request: the JWT access token, the DPoP proof, and the binding between them. Duende’s DPoP validation package does the heavy lifting here, so the API code stays short. The important part is ConfigureDPoPTokensForScheme, which wires DPoP enforcement into the existing JWT bearer authentication scheme rather than requiring a parallel authentication pipeline.
services.AddAuthentication("dpoptokenscheme")
.AddJwtBearer("dpoptokenscheme", options =>
{
options.Authority = stsServer;
options.TokenValidationParameters
.ValidateAudience = false;
options.MapInboundClaims = false;
options.TokenValidationParameters
.ValidTypes = new[] { "at+jwt" };
});
services.ConfigureDPoPTokensForScheme("dpoptokenscheme");
builder.Services.AddAuthorization(options =>
options.AddPolicy("protectedScope", policy =>
{
policy.RequireClaim("scope", "scope-dpop");
})
);
Note the ValidTypes restriction to at+jwt. This rejects any token that is not explicitly typed as an OAuth access token, which closes off a class of token confusion attacks where an ID token or a token meant for a different audience gets replayed against the API. If you are retrofitting DPoP onto an existing API, check whether your current token validation already enforces this typ header, because many hand-rolled JWT bearer setups skip it.
Watching the flow in practice
Running all three applications together makes the moving pieces easier to see. The authorization request from the web client to the identity server includes a dpop_jkt parameter, which carries the thumbprint of the client’s public key, alongside the usual PKCE code_challenge.
https://localhost:5001/connect/authorize?
client_id=web-dpop
&redirect_uri=https%3A%2F%2Flocalhost%3A5007%2Fsignin-oidc
&response_type=code
&scope=openid%20profile%20scope-dpop%20offline_access
&code_challenge=fDrpFF8OTmalCNi6KeM-L3CX-Pa8Hozsnw6vf-Q9_Tk
&code_challenge_method=S256
&nonce=...
&dpop_jkt=loM5ro2mqcEyBS46Z8CN1bP_wlYn2XPaGmTXQUzQc94
&state=...
This is the authorization code flow binding to DPoP: the key thumbprint is committed early, before the token is even issued, so the identity server can carry it through to the cnf claim later. Decoding the resulting access token shows exactly that.
{
"iss": "https://localhost:5001",
"nbf": 1691659611,
"iat": 1691659611,
"exp": 1691663211,
"aud": "https://localhost:5001/resources",
"cnf": {
"jkt": "loM5ro2mqcEyBS46Z8CN1bP_wlYn2XPaGmTXQUzQc94"
},
"scope": [
"openid",
"profile",
"scope-dpop",
"offline_access"
],
"amr": [ "pwd" ],
"client_id": "web-dpop",
"sub": "1cdddaaf-c671-4726-841b-d9b4abdede3d",
"auth_time": 1691659610,
"idp": "local",
"sid": "7602A8B025FF27CFE5ED34C62DC10B8E",
"jti": "5EC3ECF725B4BA3F608323C380FCD6F6"
}
The jkt value inside cnf matches the dpop_jkt sent in the authorization request. This is the binding the API will check on every call. Alongside this token, the client sends a separate DPoP proof JWT in its own header, one that is regenerated fresh for each request rather than reused.
{
"alg": "ES384",
"typ": "dpop+jwt",
"jwk": {
"kty": "EC",
"x": "c_Ua8nenm8XjoXvxcFvonuNeJgYg3YBAvhY2zuBI5IYl1mOhMFHWtacGoLfzA11W",
"y": "zXJSqLxYgyqq3jPdBeuqgcvuW9d4JwVL_fgsqwT8wvr05uihuU5FsX3DY-LtGF7E",
"crv": "P-384"
}
}
{
"jti": "xaWoIEMRqtRbrta13AVceLUVJxW1zqbl2RcQhmuG0nA",
"htm": "GET",
"htu": "https://localhost:5005/api/values",
"iat": 1691659934,
"ath": "5p3pmE3nvOURS-mZoEsPZkWb49vFNp7cuFrXs1mITxM"
}
Here htm and htu pin the proof to a specific HTTP method and URL, so a proof captured for one endpoint cannot be replayed against another. The jti and iat fields let the API reject proofs that are too old or that it has already seen once, closing the replay window down to a matter of seconds. The ath claim is a hash of the access token itself, tying the proof to that particular token rather than any token signed with the same key. Between the cnf claim, the jkt thumbprint, and this proof, the API has everything it needs to confirm the caller actually holds the private key, without ever seeing the key itself.
Trade-offs and where DPoP does not fit cleanly
DPoP is not a free upgrade. Every client that adopts it needs a secure place to generate and store a private key, which is straightforward on a server-side web app like this one but genuinely harder on a single-page application running entirely in the browser, where there is no equivalent of the OS keychain. Native mobile and desktop apps are a good fit because they have secure storage APIs; browser-based SPAs are the case where you need to think carefully, since a compromised page can often reach whatever key material is available to JavaScript.
There is also an operational cost. Every API call now needs proof-token generation and validation, which is more CPU work than checking a bearer token signature, and every intermediate proxy, gateway, or load balancer in the path needs to pass the DPoP header through unmodified. If you are running this behind Azure API Management or a similar gateway, confirm the gateway is not stripping or caching that header, because caching a response tied to a specific proof will break the very next request.
Mutual TLS is the other common way to get sender-constrained tokens, and it is worth comparing the two. mTLS binds the token to a client certificate at the transport layer, which is often simpler to reason about but requires certificate distribution and rotation infrastructure, and it gets awkward once you introduce load balancers or CDNs that terminate TLS before your application sees the connection. DPoP works at the application layer instead, so it survives TLS termination points more gracefully, at the cost of needing library support in every client and every API framework. Neither is strictly better; the choice usually comes down to what your existing infrastructure already supports.
If you are targeting Microsoft Entra ID rather than Duende IdentityServer, check current MSAL documentation before committing to this pattern, since proof-of-possession support in the Microsoft identity platform has been evolving separately from the OAuth working group draft and the exact claim names and enforcement points can differ from what Duende implements here.
Leave a Reply