DEV Community

Ali Suleyman TOPUZ
Ali Suleyman TOPUZ

Posted on Originally published at topuzas.Medium on

I Built DPoP in .NET 10 by Hand. Then I Found the Package That Makes It Unnecessary.

I read Michael Maurice’s “Stop Trusting Bearer Tokens” piece the same week it went up, because I had a client asking a version of the same question: our API tokens get logged in more places than I’d like, what actually stops someone from replaying a stolen one. His article is a solid on-ramp. It gets the core idea right, walks through a four-project solution, and doesn’t reach for a third-party JOSE library, which I respect. But I closed the tab with more questions than I opened it with. Is the validation sequence he shows actually complete against the RFC, or a simplified version for readability? Do I really need to hand-roll a nonce store and a JWK thumbprint function in 2026, or did someone already ship that as a NuGet package while I wasn’t looking? And if I’m testing this locally, do I need an Auth0 account, or can I stand up an identity provider on my own machine for free?

So I built it twice. Once by hand, against the actual text of RFC 9449, to understand every piece. Then again using a package that turned out to already exist. This is the writeup of both attempts, plus the mistakes I made in between.

The problem in one paragraph, for anyone who skipped the RFC

A bearer token is exactly what it sounds like: whoever bears it, holds it, gets access. If that token leaks through an XSS payload, a misconfigured logging pipeline, a malicious browser extension, or a compromised CI runner, the attacker doesn’t need your password or your session, they just need the string. DPoP (Demonstrating Proof-of-Possession, RFC 9449) fixes this by binding the token to a public/private keypair the client controls. The access token gets stamped with the SHA-256 thumbprint of the client’s public key at issuance. Every request after that has to come with a short-lived JWT, signed by the matching private key, that proves the caller still holds that key and is making this specific request, to this specific URL, right now. Steal the token without the private key and you have a string that the API will reject on sight.

This is not the only way to solve the problem. Mutual TLS does something similar at the transport layer with X.509 certificates, and it’s what FAPI 2.0 open banking deployments often use instead. But mTLS needs certificate lifecycle management and TLS termination control that browsers and most mobile apps don’t have. DPoP works entirely at the application layer with ordinary JWTs, which is why it’s the one showing up in OAuth 2.1 guidance and, more recently, in discussions around MCP and AI agent tooling, where a single leaked token can mean an agent acting with someone else’s authority indefinitely.

What a DPoP proof actually has to contain

Here’s the anatomy, straight from RFC 9449 section 4.2, formatted the way I wish someone had shown it to me the first time:

+--------+----------+---------------------------------------------------+
| Field | Location | Required when |
+--------+----------+---------------------------------------------------+
| typ | header | Always. Must equal "dpop+jwt" exactly. |
| alg | header | Always. Must be asymmetric (ES256, PS256, etc). |
| | | "none" and symmetric algorithms are forbidden. |
| jwk | header | Always. The public key only, never the private half. |
| jti | payload | Always. Unique per proof, negligible collision odds. |
| htm | payload | Always. HTTP method of the current request. |
| htu | payload | Always. Request URI, no query string, no fragment. |
| iat | payload | Always. Timestamp the proof was created. |
| ath | payload | Only when an access token is presented alongside it. |
| | | Base64url(SHA-256(access token)). |
| nonce | payload | Only when the server has issued one via DPoP-Nonce. |
+--------+----------+---------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

The ath and nonce rows are the ones people miss. ath binds the proof to a specific access token, not just to the client, which matters because otherwise a valid proof for one token could plausibly be reused with a different stolen token from the same client. nonce is optional at the resource server level but the RFC explicitly gives servers the right to demand one, which turns a stateless-looking proof into something the server can force to be fresh within a tight window.

The twelve checks, not ten, not “roughly ten”

This is the part I wanted a precise answer on, because the difference between “roughly validate the proof” and “validate all twelve things the spec requires” is exactly the gap where a real vulnerability hides. RFC 9449 section 4.3 lists them, and I’m keeping the order I actually implemented in, which groups the cheap syntactic checks before the expensive cryptographic ones so a malformed proof fails fast:

1. Exactly one DPoP header is present on the request.
2. The header value parses as a well-formed JWT.
3. All required header and payload fields from the table above are present.
4. typ equals "dpop+jwt".
5. alg is asymmetric, registered, supported, and not "none".
6. The jwk header contains no private key material (no "d" member).
7. The signature verifies against the public key in jwk.
8. htm matches the HTTP method of the current request.
9. htu matches the request URI after normalization.
10. If the server issued a nonce, the nonce claim matches it exactly.
11. iat falls inside an acceptable freshness window.
12. If an access token is attached, ath matches its hash AND the token's
    cnf.jkt matches the thumbprint of this proof's public key.
Enter fullscreen mode Exit fullscreen mode

Miss step 6 and you’ll happily accept a proof whose “public” key came bundled with its private half, which tells you the client’s key management is broken somewhere but doesn’t itself break your server, so it’s easy to skip during a first pass. Miss step 12’s second half and you’ve built a system that checks the proof is internally consistent without ever confirming it belongs to the token being presented, which defeats the entire point.

Building the pieces by hand

The keypair and the JWK thumbprint

RFC 7638 defines the thumbprint as a SHA-256 hash of a canonical JSON representation of the JWK: only the required members, in lexicographic key order, with no insignificant whitespace. For an EC key that’s crv, kty, x, y, in that order. Get the ordering wrong and your thumbprints will never match anyone else's, silently, with no error to point you at the bug.

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public static class DPoPKey
{
    public static (ECDsa Key, Dictionary<string, object> PublicJwk, string Thumbprint) CreateSigningKey()
    {
        var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var parameters = key.ExportParameters(includePrivateParameters: false);
        var x = Base64UrlEncode(parameters.Q.X!);
        var y = Base64UrlEncode(parameters.Q.Y!);
        // RFC 7638: required members only, lexicographic order, no whitespace.
        var canonicalJwk = $$"""{"crv":"P-256","kty":"EC","x":"{{x}}","y":"{{y}}"}""";
        var thumbprint = Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(canonicalJwk)));
        var publicJwk = new Dictionary<string, object>
        {
            ["kty"] = "EC",
            ["crv"] = "P-256",
            ["x"] = x,
            ["y"] = y
        };
        return (key, publicJwk, thumbprint);
    }
    public static string Base64UrlEncode(byte[] bytes) =>
        Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
Enter fullscreen mode Exit fullscreen mode

The client, building a proof

I used SecurityTokenDescriptor.AdditionalHeaderClaims instead of hand-assembling a JWT string, because it lets Microsoft.IdentityModel.Tokens handle the signing while still letting me inject the typ and jwk header parameters DPoP requires but standard JWT libraries don't know about.

using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;

public static class DPoPProofFactory
{
    public static string CreateProof(
        ECDsa key,
        Dictionary<string, object> publicJwk,
        HttpMethod method,
        Uri requestUri,
        string? accessToken = null,
        string? nonce = null,
        TimeProvider? timeProvider = null)
    {
        var now = (timeProvider ?? TimeProvider.System).GetUtcNow();
        var claims = new Dictionary<string, object>
        {
            ["jti"] = Guid.NewGuid().ToString("N"),
            ["htm"] = method.Method,
            ["htu"] = NormalizeUri(requestUri),
            ["iat"] = now.ToUnixTimeSeconds()
        };
        if (accessToken is not null)
            claims["ath"] = DPoPKey.Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)));
        if (nonce is not null)
            claims["nonce"] = nonce;
        var descriptor = new SecurityTokenDescriptor
        {
            Claims = claims,
            SigningCredentials = new SigningCredentials(new ECDsaSecurityKey(key), SecurityAlgorithms.EcdsaSha256),
            AdditionalHeaderClaims = new Dictionary<string, object>
            {
                ["typ"] = "dpop+jwt",
                ["jwk"] = publicJwk
            }
        };
        var handler = new JwtSecurityTokenHandler();
        return handler.WriteToken(handler.CreateToken(descriptor));
    }
    // htu strips query and fragment, per section 4.2. Do this against the
    // externally visible URL, not whatever Kestrel sees behind a proxy,
    // or every request behind a load balancer fails for the wrong reason.
    private static string NormalizeUri(Uri uri) => uri.GetLeftPart(UriPartial.Path);
}
Enter fullscreen mode Exit fullscreen mode

Binding the token at issuance

The authorization server takes the JWK out of the client’s first DPoP proof (sent with the token request itself) and stamps its thumbprint into the token as cnf.jkt. I also changed token_type from Bearer to DPoP, which is easy to forget and which some client libraries key off of to decide whether to attach a proof at all.

app.MapPost("/connect/token", async (TokenRequest request) =>
{
    // client auth and grant validation happen above this line
    if (request.DPoPProof is null)
        return Results.BadRequest(new { error = "invalid_request", error_description = "DPoP proof required" });
    var jwk = DPoPProofReader.ExtractJwk(request.DPoPProof);
    var jkt = DPoPProofReader.ComputeThumbprint(jwk);
    var descriptor = new SecurityTokenDescriptor
    {
        Issuer = "https://auth.example.com",
        Audience = "api1",
        Expires = DateTime.UtcNow.AddMinutes(5),
        SigningCredentials = signingCredentials,
        Claims = new Dictionary<string, object>
        {
            ["sub"] = request.ClientId!,
            ["cnf"] = new Dictionary<string, object> { ["jkt"] = jkt }
        }
    };
    var handler = new JwtSecurityTokenHandler();
    var token = handler.WriteToken(handler.CreateToken(descriptor));
    return Results.Ok(new { access_token = token, token_type = "DPoP", expires_in = 300 });
});
Enter fullscreen mode Exit fullscreen mode

Validating the proof on the way in

This is where all twelve checks live, plus replay detection using HybridCache, which shipped as a stable API in time to make a hand-rolled ConcurrentDictionary replay store feel dated.

public sealed class DPoPProofValidator(HybridCache cache, TimeProvider timeProvider)
{
    private static readonly TimeSpan ProofLifetime = TimeSpan.FromSeconds(60);
    public async Task<DPoPValidationResult> ValidateAsync(
        HttpRequest request, string accessToken, string expectedJkt, CancellationToken ct)
    {
        if (!request.Headers.TryGetValue("DPoP", out var values) || values.Count != 1)
            return Fail("missing_or_duplicate_dpop_header");
        var handler = new JwtSecurityTokenHandler();
        if (!handler.CanReadToken(values[0]))
            return Fail("malformed_proof");
        var jwt = handler.ReadJwtToken(values[0]);
        if (jwt.Header.Typ != "dpop+jwt")
            return Fail("wrong_typ");
        if (jwt.Header.Alg is not ("ES256" or "PS256"))
            return Fail("unsupported_alg");
        if (jwt.Header["jwk"] is not JsonElement jwkElement || jwkElement.TryGetProperty("d", out _))
            return Fail("missing_or_leaking_jwk");
        var publicKey = DPoPProofReader.ImportEcdsaPublicKey(jwkElement);
        if (!DPoPProofReader.VerifySignature(values[0]!, publicKey))
            return Fail("bad_signature");
        var jkt = DPoPProofReader.ComputeThumbprint(jwkElement);
        if (!CryptographicOperations.FixedTimeEquals(
                Encoding.ASCII.GetBytes(jkt), Encoding.ASCII.GetBytes(expectedJkt)))
            return Fail("token_not_bound_to_this_key");
        if (jwt.Claims.FirstOrDefault(c => c.Type == "htm")?.Value != request.Method)
            return Fail("method_mismatch");
        if (jwt.Claims.FirstOrDefault(c => c.Type == "htu")?.Value != DPoPProofReader.ExternalUri(request))
            return Fail("uri_mismatch");
        var iat = DateTimeOffset.FromUnixTimeSeconds(long.Parse(jwt.Claims.First(c => c.Type == "iat").Value));
        var now = timeProvider.GetUtcNow();
        if (iat < now - ProofLifetime || iat > now + TimeSpan.FromSeconds(5))
            return Fail("proof_expired_or_from_the_future");
        var expectedAth = DPoPKey.Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)));
        var ath = jwt.Claims.FirstOrDefault(c => c.Type == "ath")?.Value;
        if (ath is null || !CryptographicOperations.FixedTimeEquals(
                Encoding.ASCII.GetBytes(ath), Encoding.ASCII.GetBytes(expectedAth)))
            return Fail("access_token_hash_mismatch");
        var jti = jwt.Claims.First(c => c.Type == "jti").Value;
        var claimedFirst = false;
        await cache.GetOrCreateAsync(
            $"dpop:{jkt}:{jti}",
            _ => { claimedFirst = true; return ValueTask.FromResult(true); },
            new HybridCacheEntryOptions { Expiration = ProofLifetime },
            cancellationToken: ct);
        return claimedFirst
            ? new DPoPValidationResult { IsValid = true, JktThumbprint = jkt }
            : Fail("proof_replayed");
        DPoPValidationResult Fail(string error) => new() { IsValid = false, Error = error };
    }
}
Enter fullscreen mode Exit fullscreen mode

The replay check is the one I got wrong on the first pass. My original version did TryGetValueAsync then SetAsync as two separate calls, which is a textbook check-then-act race: two requests carrying the same replayed proof, arriving close enough together, could both pass the check before either finished the write. HybridCache.GetOrCreateAsync gives you single-flight behavior instead, the factory delegate only runs for the caller that actually creates the entry, so claimedFirst only comes back true once per jti, even under concurrent load. I'd rather lean on a guarantee the cache already provides than write my own locking around a dictionary.

Where a reverse proxy quietly breaks step 9

The other mistake, and the one that took longer to notice because it only showed up after I deployed behind a load balancer: htu has to match the URL the client actually called, which behind a reverse proxy is almost never what HttpRequest.GetDisplayUrl() gives you out of the box unless ForwardedHeadersMiddleware is configured correctly to trust X-Forwarded-Proto and X-Forwarded-Host. Without it, Kestrel sees http://10.0.4.12:8080/api/orders, the client's proof says https://api.example.com/api/orders, and every single request fails validation with a URI mismatch that has nothing to do with security and everything to do with a missing middleware registration. I lost most of an afternoon to this before I thought to log both URIs side by side.

+---------------------------------------------+------------------------------------------+
| Gotcha | What actually happens |
+---------------------------------------------+------------------------------------------+
| Check-then-act replay store (Get then Set) | Two requests with the same replayed jti can |
| | both pass validation in a tight race |
+---------------------------------------------+------------------------------------------+
| Forwarded headers not configured behind a | htu never matches the externally visible URL,|
| reverse proxy or load balancer | every request fails uri_mismatch |
+---------------------------------------------+------------------------------------------+
| Skipping the "no private key in jwk" check | A client that mishandles its own key export |
| | gets waved through instead of rejected |
+---------------------------------------------+------------------------------------------+
| In-memory nonce or replay cache on a service | Works fine locally, then silently stops |
| that scales to more than one instance | catching replays the moment you scale out |
+---------------------------------------------+------------------------------------------+
| token_type left as "Bearer" at issuance | Some client libraries decide whether to send |
| | a DPoP proof based on this field |
+---------------------------------------------+------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Then I found out I didn’t have to write most of this

While I was mid-implementation, I went looking for prior art and found that Duende shipped Duende.AspNetCore.Authentication.JwtBearer 1.0.0 in February this year, which is exactly the resource-server half of what I'd just spent two days writing. It hooks into the standard JwtBearer handler and adds DPoP validation without touching anything I described above by hand.

// dotnet add package Duende.AspNetCore.Authentication.JwtBearer
builder.Services.AddAuthentication("token")
    .AddJwtBearer("token", options =>
    {
        options.Authority = "https://auth.example.com";
        options.Audience = "api1";
    });
builder.Services.ConfigureDPoPTokensForScheme("token", options =>
{
    options.EnableReplayDetection = false; // turn on for production, off while you're testing locally
    options.AllowBearerTokens = true; // lets you migrate clients incrementally instead of a hard cutover
});
Enter fullscreen mode Exit fullscreen mode

Replay detection under the hood also uses HybridCache, with a documented note that you need a distributed backend like Redis once you're running more than one instance, which is exactly the gotcha in my table above, just already solved. Default proof lifetime is five seconds, tighter than the sixty I used above, and configurable through ProofTokenLifetime if your clock skew tolerance needs more room.

What it doesn’t do: issue tokens. That’s still the authorization server’s job, and Duende’s docs point to IdentityServer’s Enterprise Edition for that half, with Duende.AccessTokenManagement handling the client side of the protocol. So "hand-roll it" and "use the package" aren't fully separate paths, you still need something acting as the AS that understands cnf.jkt binding, whether that's Duende IdentityServer, Keycloak, or your own token endpoint like the one I wrote above.

+------------------------+---------------------------+---------------------------+
| Approach | Best for | What you still write |
+------------------------+---------------------------+---------------------------+
| Hand-rolled (this | Learning the protocol, | Everything, including |
| article's first half) | non-Duende auth servers, | replay storage and the |
| | tight control over behavior | JWK thumbprint function |
+------------------------+---------------------------+---------------------------+
| Duende.AspNetCore. | Production APIs already on | The authorization server's |
| Authentication.JwtBearer| JwtBearer auth, want the | token issuance and cnf.jkt |
| | RFC checks maintained for | binding |
| | you | |
+------------------------+---------------------------+---------------------------+
| Mutual TLS instead of | Server-to-server or device | Certificate issuance and |
| DPoP entirely | contexts with PKI already | rotation, which browsers |
| | in place | and most mobile apps can't |
| | | do at all |
+------------------------+---------------------------+---------------------------+
Enter fullscreen mode Exit fullscreen mode

If I were shipping this into a production API tomorrow, I’d use the package. The value of building it by hand wasn’t the code I ended up keeping, it was that I now read a validation failure log and know exactly which of the twelve checks failed and why, instead of treating the middleware as a black box.

Testing this without paying for anything

You don’t need an Auth0 or Okta account to try any of this locally. Keycloak has had DPoP support for a while, and it’s a per-client toggle, not a paid add-on.

# docker-compose.yml
services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.7
    command: start-dev
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - "8080:8080"

docker compose up -d
# open http://localhost:8080, log in with admin/admin
# create a realm, create a client, then in that client's
# Settings -> Capability config, flip on "Require DPoP bound tokens"
Enter fullscreen mode Exit fullscreen mode

With that switch on, Keycloak rejects a token request that doesn’t include a DPoP proof, and any access token it issues carries cnf.jkt. Point your resource server's Authority at the realm and you can run the exact validator above, or the Duende package, against real tokens without writing your own authorization server first. If you'd rather see it fail before you see it succeed, try calling your API with a plain curl -H "Authorization: Bearer $TOKEN" and no DPoP header. If your validator is wired up correctly, you get a 401 with WWW-Authenticate: DPoP, not a silent pass.

Why this is worth doing now, specifically

Bearer token theft isn’t a new risk, but the shape of who’s making requests on a user’s behalf changed faster than most APIs’ threat models did. OAuth 2.1 and the emerging guidance around MCP both push toward sender-constrained tokens for exactly this reason: an AI agent holding a long-lived bearer token is a much bigger blast radius than a browser tab holding one, because the agent can act at machine speed across many tool calls without a human noticing the token even moved. Bluesky’s OAuth profile already requires DPoP on every request, not as an option. That’s the direction this is heading, and building the muscle now, even by hand once, means the next time a client or a spec pushes DPoP onto you, it isn’t a mystery you’re debugging under a deadline.

I don’t think everyone needs to write a JWK thumbprint function from scratch. But I’m glad I did it once before I let a package do it for me. Knowing exactly which of the twelve checks a 401 corresponds to is the difference between fixing a bug in ten minutes and staring at WWW-Authenticate: DPoP wondering what your own middleware is even doing.

Tags: dotnet, oauth2, dpop, api-security, aspnetcore, csharp, cybersecurity

Top comments (0)