Today we are excited to announce version 1.0 of our Duende.AspNetCore.Authentication.JwtBearer (JwtBearer Extensions) package, which helps you implement Demonstrating Proof-of-Possession (DPoP) in .NET-powered APIs. This JwtBearer Extensions package is an easy-to-use extension for the JwtBearer authentication handler that you're already using with ASP.NET Core. To get started, you only need a single NuGet package and minimal configuration, with support for advanced protocol features like replay detection and server-issued nonces, signing algorithm configuration, clock skew support, and enables extensibility.
But what's the big deal with this package? What is DPoP, and why do you need it? In this article, we'll see why you want to use DPoP to make your applications more secure, and how you can protect against a number of threats, such as replay attacks using JwtBearer Extensions.
What is DPoP
DPoP protects you against one of the most significant threats in the OAuth ecosystem: abuse of stolen access tokens. The protocols that we use to obtain tokens have been vetted extensively by OAuth community and security researchers. Especially if you follow Best Current Practice and use a secure protocol implementation, there's not much of an attack surface to exploit in the protocols themselves. But you must also store and transmit tokens to APIs in order to use them. That's largely outside the scope of security standards, but has historically been a weak point.
Protecting tokens from exfiltration is quite challenging. Tokens are often handled by untrusted client environments, such as browsers and mobile platforms. Storing tokens in the browser exposes them to the dangers of malicious JavaScript from other sites and browser plugins. Attackers know this weakness, and often target the storage and management of tokens in browser-based clients.
While tokens in browser-based clients face a unique risk, tokens used with APIs face a different threat altogether. Tokens can be stolen by malicious code running in APIs. If you use the same token for calls to multiple APIs and one of those APIs is compromised, requests to that compromised API reveal tokens to the attacker, who then gains access to any other resources protected by the same token. Tokens are transmitted to APIs over networks that may be hostile, and badly behaved systems might store access tokens insecurely or write access tokens to log files. Exfiltrated tokens can expose sensitive systems to unauthorized callers and leave you open to unnecessary risks.
These are just some of the ways in which an attacker can steal tokens. Once an attacker has access to a stolen token, they can use it to make API calls as if they were the original token owner. Token reuse occurs because access tokens are typically bearer tokens, meaning that any bearer, or holder, of the token can use it without additional checks around the caller’s identity. DPoP prevents this abuse by sender-constraining tokens so that only the party that was issued a token can use it. This is accomplished by binding the token to a public-private key pair held by the client.
The client proves possession of the private key by signing a specialized JSON Web Token (JWT) called a DPoP Proof Token. Whenever the client wants to use its token, it must produce a new proof because proofs are short-lived and specific to a particular endpoint. Proofs make a stolen access token unusable by an attacker who does not possess the private key.
The JwtBearer Extensions Library
APIs must verify the binding between the access token and the proof token in order to ensure that the access token is properly sender-constrained. There are many steps an API must perform in order to properly and securely verify the proof. If you read the spec, there are a dozen steps in the verification process, as well as security guidance and additional rules for verifying the proof's lifetime, which optionally include a server-issued nonce mechanism.
That's a lot of details to get right. While the specification is clear and well-written, it makes sense to rely on a library to make sure your implementation is solid. That's where JwtBearer Extensions comes in. We handle the protocol details for you, so you focus on the business logic of your APIs.
To get started, install the NuGet package:
dotnet add package Duende.AspNetCore.Authentication.JwtBearer
Then configure JwtBearer Extensions:
// Keep your existing code that configures the JwtBearer handler unchanged:
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options => { /* Your existing configuration here */ });
// Add DPoP support with our extensions:
builder.Services.ConfigureDPoPTokensForScheme("token", options =>
{
options.EnableReplayDetection = false;
options.AllowBearerTokens = true;
});
Note: In this example, we've disabled replay detection to show a minimal setup. See below for more details about detecting and mitigating replay attacks.
We've also turned on the AllowBearerTokens flag, which does exactly what it sounds like: with the flag enabled, DPoP is optional. If you're in the process of migrating to DPoP, it can be useful to run in a transitional mode where both bearer and DPoP tokens are allowed.
Advanced DPoP Threats
While a basic DPoP implementation provides strong security, there are additional hardening measures you can enable. In this section, we'll explore two key threats and their mitigations: replay attacks and pre-generation of proofs.
Threat 1: Replay Attacks
Replay attacks involve an attacker who can steal DPoP proofs. An attacker with the proof can use it to make a request to the endpoint where the proof was originally used, thereby bypassing DPoP protection.
Attacker Requirements
Note that such an attacker requires sophisticated capabilities. Proofs are sent to the API via HTTP headers sent over HTTPS. Therefore, TLS protects the proof from eavesdropping. However, attacks against TLS are possible. For example, it's fairly common for TLS to terminate at a proxy in front of the nodes in an API's cluster. If an attacker can get behind that proxy, they could steal proofs.
Threat Modeling
How concerned you are about an attacker with these capabilities is ultimately a decision you have to make. This is what we mean when we talk about threat modeling. Security isn't a binary choice, where "this is more secure" means "we absolutely must therefore do it". Instead, we should understand the threats we face and evaluate possible mitigations for those threats in order to make an assessment of which security controls should be applied.
To make this assessment, we need to understand the capabilities an attacker would need in order to carry out an attack. It's also important to consider the impact that an attack would have, based on the sensitivity and value of the resources that we are protecting, the risk to an organization's reputation, etc. And finally, the operational, financial, and resource costs of mitigation must also be weighed. You could prevent all attacks on your website by shutting it down completely, but that's too high a cost for most of us to pay. In this spirit of threat modeling, there are two possible mitigations against the threat of proof replay to consider.
Mitigation 1 - Short Lifetime
The most straightforward mitigation is to limit the lifetime of the proofs. The shorter the lifetime, the less time that an attacker has to abuse a stolen proof. Clients will generate new proofs every time they make a request to an API, so the proof only needs to be valid long enough to be sent to the API and validated. In JwtBearer Extensions, we default to a lifetime of 5 seconds, but this can be customized via the ProofTokenLifetime option:
builder.Services.ConfigureDPoPTokensForScheme("token", options =>
{
options.ProofTokenLifetime = TimeSpan.FromSeconds(2);
});
Mitigation 2 - Enforcing jti Uniqueness
A more thorough, but also complex-to-deploy solution is to track the proofs used. All proofs contain a jti (JWT Token Identifier) claim, which is the unique identifier of the proof. We can cache jti values as we see them and check incoming proofs to see if they are in the cache. When we see a duplicate jti value, we know that proofs are being replayed and can reject the proof.
Tracking proof usage provides a very strong defense against replay, but it comes at the cost of requiring a cache of jti values. In load-balanced scenarios, this means that you're going to need some sort of shared distributed cache, like Redis or Valkey, to store the jti values that have been seen so far. This additional piece of infrastructure entails greater complexity and cost. It will have some performance impact, as every incoming proof will have to check the distributed cache to detect replay. Evaluating the benefits against the cost of this mitigation will depend on your particular situation and organizational needs.
Cache Characteristics
The values in the cache don't need to be kept for very long, because we don't need to defend against replay of expired tokens. The expiration check will already catch any attempt to reuse a token.
Once a token expires, we can evict it from the cache. JwtBearer Extensions caches for the proof lifetime plus twice the clock skew. The jti values are also hashed before caching to prevent an attacker from overwhelming writes to the cache by sending proofs with large jti values. This hashing ensures that each cache entry is consistently sized, containing a key (67 characters, including the identifying prefix and the hash), and a boolean flag. The size of each entry will depend on a variety of factors, notably overhead from your particular distributed cache, but the size of the data coming from JwtBearer Extensions will be consistent.
When you consider how much memory your cache needs to accommodate, consider that every proof you see will generate a record with the size and lifetime described above. For example, if you handle 1,000 requests/second with the default 5-second lifetime and 5-second clock skew, expect approximately 1,000 req/sec × (5s lifetime + 2×5s clock skew) = 15,000 entries. The storage and access speed considerations will depend on your particular caching solution.
JwtBearer Extensions Setup
Tracking jti values is built into JwtBearer Extensions and turned on by default. We use .NET's HybridCache, which provides a mix of in-memory and distributed caches.
We don't register a HybridCache implementation by default. In load-balanced deployments, an in-memory cache would only protect against replays hitting the same server instance. Instead, the developer using JwtBearer Extensions must explicitly either opt out of Replay Detection or choose the appropriate caching mechanism for their situation.
Here's how to use Redis as the distributed cache for replay detection:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration =
builder.Configuration.GetConnectionString("RedisReplayCache");
});
builder.Services.AddKeyedHybridCache( ServiceProviderKeys.ProofTokenReplayHybridCache);
builder.Services.ConfigureDPoPTokensForScheme("token", options =>
{
options.EnableReplayDetection = true; // (Default value)
});
Tip: Explore the Microsoft Learn documentation to learn more about distributed caching and Redis.
Threat 2: Pre-generation of Proofs
If an attacker gains control of a client, they can generate proofs for future use by changing the time that the proof claims to be issued at by setting an iat value. This concern was raised during the development of the DPoP specification by security architects at Microsoft, and their scenario was one of internal threats - e.g., a bank employee creates proofs, exfiltrates those proofs, and thereby bypasses security or auditing controls. If an attacker briefly gains access to a private key on a hardware security key or other non-extractable secret, they can generate proofs and extract them. Even though the private key is non-extractable, the pre-generated proofs mean that the security benefits of a non-extractable key (for some number of pre-generated proofs) are negated. The attacker has a token stockpile for any number of future access attempts.
On the other hand:
- Pre-generated proofs can only be used for the lifetime of the access token. Proofs are bound to a particular access token with the
athclaim, and when that token expires, the proof is no longer useful. - This attack vector involves an attacker who controls the client. In that situation, many other (arguably worse) attacks are possible. The attacker can drive an entire attack through the compromised or malicious client.
Defending against pre-generation requires server-generated nonces. A server-generated nonce is a value supplied by the API that the client must include in its proof. The nonce is meant to represent the server's time. In JwtBearer Extensions, we create this value by data protecting (encrypting and signing) the current time on the server. Because this value is not under the client’s control, pre-generation of proofs is defeated by this mechanism. Applications obtain nonces by sending an otherwise valid proof to the server. The server then responds with an HTTP error and provides the nonce to use.
Applications may reuse nonces over their lifetime, but because that lifetime is short, using server-issued nonces results in a large increase in network requests between the app and the API. Every request to an API using server-generated nonces may require two round-trips: the first to obtain the nonce, and the second to actually use it.
Again, threat modeling can help you make an informed choice as you consider this feature.
The default behavior in JwtBearer Extensions is to use the client-provided iat value to validate lifetimes. You can enable server nonces like this:
builder.Services.ConfigureDPoPTokensForScheme("token", options =>
{
options.ProofTokenExpirationMode = ExpirationMode.Nonce;
});
Recommendations and Conclusion
JwtBearer Extensions provides secure defaults out of the box. While you should always consider your threat model and specific circumstances, our recommendations for most production deployments are
- Keep proof lifetimes short
- Enable replay detection (on by default, and requires distributed cache)
- Use client-supplied
iatvalues for lifetime validation, switching to server nonces in high-security scenarios such as banking or healthcare.
If you don't have a distributed caching infrastructure and don't want to add that dependency, you can consider disabling replay detection, though this reduces protection against sophisticated attackers.
With JwtBearer Extensions, improving your security posture by adopting DPoP is easier than ever! With the combination of IdentityServer Enterprise Edition issuing DPoP tokens, Duende.AccessTokenManagement handling the protocol on the client side, and now JwtBearer Extensions for the API, you have all the libraries you need to use DPoP. Let's put abuse of stolen tokens in the rearview mirror. Get started now!