How to Prevent OAuth Token Replay in Quarkus
Implement DPoP-bound tokens and custom nonce providers to harden your Java REST APIs against replay attacks.
Most developers think bearer tokens are “secure enough.” You configure OIDC, validate JWT signatures, and you’re done. The token expires in five minutes. What could go wrong?
The problem is simple. Bearer tokens are reusable. If someone gets the token, they can use it. There is no binding to a client. No proof that the caller is the original holder. If that token leaks through logs, browser storage, a proxy, or a man-in-the-middle attack, your API accepts it. Until it expires.
DPoP (Demonstration of Proof-of-Possession, RFC 9449) fixes this by binding the token to a key pair. The client signs every request with its private key. The access token contains the public key thumbprint (cnf). The server checks both. A stolen token without the private key is useless.
DPoP is primarily for SPAs (public OIDC clients): the browser (or SPA backend) logs the user in at the authorization server, which issues an access token bound to the DPoP proof—the token includes the thumbprint of the public key in the proof, and the resource server verifies that the proof’s key matches. The same key pair is used when requesting the token and when calling the API. See the Quarkus blog on sender-constraining tokens and the Quarkus oidc-dpop integration test (FrontendResource flow) for reference.
But even DPoP has a replay window. If someone captures a DPoP proof JWT and replays it quickly enough, it may still pass validation. This is where server-provided nonces come in. The server challenges the client. The client signs the nonce. The nonce is single-use and short-lived. Replay attacks fail.
Before Quarkus 3.32, you could enable DPoP verification. But you could not control nonce lifecycle. Now you can. The new DPoPNonceProvider SPI gives you full control over nonce issuance and validation.
In this tutorial we build a Quarkus REST API protected with DPoP-bound tokens and a custom nonce provider. Access tokens are issued by Keycloak with DPoP binding (the token’s cnf claim matches the proof’s public key). We use Keycloak Dev Services with DPoP enabled and verify the full challenge-response flow with tests.
Prerequisites
Java 21 installed
Maven 3.9+
Docker or Podman (for Keycloak Dev Services)
Basic understanding of OAuth 2 and OIDC
Project setup
Create the project or start from my Github repository.
quarkus create app org.example:dpop-demo \
--extension=quarkus-oidc,quarkus-rest,quarkus-rest-jackson \
--java=21
cd dpop-demoExtensions:
quarkus-oidc -OIDC integration, token verification, and DPoP bindingquarkus-rest -REST endpoints (Jakarta REST)quarkus-rest-jackson -JSON serialization for request/response bodies
Token verification and DPoP binding are handled by Quarkus OIDC; no separate JWT extension is needed for access tokens.
Add Nimbus JOSE + JWT as a test dependency so we can build DPoP proof JWTs in the verification tests. The proof is a signed JWT with a specific shape (header typ: dpop+jwt, jwk, and claims such as htm, htu, ath, nonce). Nimbus gives us full control over headers and claims and keeps the test code simple. Because it is only used in tests, the scope is test and it is not part of the runtime application.
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>10.8</version>
<scope>test</scope>
</dependency>Keycloak setup
We use Keycloak Dev Services with the DPoP feature enabled. Keycloak issues access tokens that are bound to the DPoP proof: when the client sends a DPoP proof to the token endpoint, Keycloak includes the public key thumbprint (cnf) in the token. The same key pair must then be used to create the DPoP proof when calling the Quarkus API.
Add a minimal realm file src/main/resources/quarkus-realm.json that defines:
Realm:
quarkusClient:
dpop-api(public, with direct access grants enabled for the resource-owner-password flow in tests; you can use authorization code in a real SPA)User:
alice/alicewith realm role account-viewer (so@RolesAllowed("account-viewer")on the balance endpoint succeeds)
The project’s quarkus-realm.json in the repo can be used as-is; adapt roles and client settings (e.g. redirect URIs for auth code) as needed for your environment. See Keycloak DPoP documentation for enabling DPoP in production.
Configuration
Configure src/main/resources/application.properties:
quarkus.oidc.client-id=dpop-api
quarkus.oidc.application-type=service
# Require DPoP proof for every request
quarkus.oidc.token.authorization-scheme=dpop
# Keycloak Dev Services: realm and DPoP feature
quarkus.keycloak.devservices.enabled=true
quarkus.keycloak.devservices.realm-path=quarkus-realm.json
quarkus.keycloak.devservices.features=dpop
%dev.quarkus.log.category."io.quarkus.oidc".level=DEBUGCritical settings:
quarkus.oidc.token.authorization-scheme=dpop— Forces Quarkus to require DPoP proofs and to verify that the access token’scnf(thumbprint) matches the proof’s public key. Without this, it would accept plain bearer tokens.quarkus.keycloak.devservices.enabled=true— Starts Keycloak in a container when you run or test the app. The auth server URL is set automatically.quarkus.keycloak.devservices.realm-path— Path to the realm JSON (classpath or file).quarkus.keycloak.devservices.features=dpop— Enables the DPoP feature in Keycloak so it issues tokens withcnf.
Protected REST resource
Create src/main/java/org/example/BankAccountResource.java:
package org.example;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/accounts")
@Authenticated
public class BankAccountResource {
@Inject
SecurityIdentity identity;
@GET
@Path("/{accountId}/balance")
@RolesAllowed("account-viewer")
@Produces(MediaType.APPLICATION_JSON)
public BalanceResponse getBalance(@PathParam("accountId") String accountId) {
String subject = identity.getPrincipal() != null ? identity.getPrincipal().getName() : null;
return new BalanceResponse(
accountId,
subject,
1_234_567);
}
public record BalanceResponse(String accountId, String owner, long balancePence) {}
}The access token is issued by Keycloak and must contain a cnf (thumbprint) claim that matches the DPoP proof’s public key; Quarkus OIDC enforces this before your code runs. This endpoint guarantees that the token is valid, the role account-viewer is present, and the DPoP proof is validated. It does not by itself protect against replay without nonces or ensure cluster-wide nonce consistency—those are handled by the custom nonce provider below.
Implementing DPoPNonceProvider
Quarkus 3.32 introduces the io.quarkus.oidc.DPoPNonceProvider interface. If you implement it as a CDI bean, Quarkus uses it automatically.
Create src/main/java/org/example/security/InMemoryDPoPNonceProvider.java:
package org.example.security;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.concurrent.ConcurrentHashMap;
import org.jboss.logging.Logger;
import io.quarkus.oidc.DPoPNonceProvider;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class InMemoryDPoPNonceProvider implements DPoPNonceProvider {
private static final Logger LOG = Logger.getLogger(InMemoryDPoPNonceProvider.class);
private static final long NONCE_TTL_SECONDS = 30;
private final ConcurrentHashMap<String, Instant> store = new ConcurrentHashMap<>();
private final SecureRandom random = new SecureRandom();
private final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
@Override
public String getNonce() {
byte[] bytes = new byte[16];
random.nextBytes(bytes);
String nonce = encoder.encodeToString(bytes);
store.put(nonce, Instant.now().plusSeconds(NONCE_TTL_SECONDS));
LOG.debugf("Issued nonce %s", nonce);
return nonce;
}
@Override
public boolean isValid(String nonce) {
if (nonce == null || nonce.isBlank()) return false;
Instant expiry = store.remove(nonce);
if (expiry == null) return false;
if (Instant.now().isAfter(expiry)) return false;
return true;
}
}The important detail is store.remove(nonce): nonces are single-use. If someone replays the same proof JWT, the nonce is already gone and validation fails.
For production clusters, use a shared store (e.g. Redis or a database) so that nonces issued on one node are visible to others.
Production hardening
Replay attempts
If an attacker reuses a proof JWT:
The nonce was already consumed.
isValid()returnsfalse.Quarkus returns 401 with a new
DPoP-Nonce(when applicable).Replay fails.
Load and restarts
Nonce generation is cheap; storage matters. With in-memory storage, a restart clears all issued nonces. Clients retry and may see more 401s until they obtain a fresh nonce. In clustered deployments, use a distributed store with TTL and atomic GET+DEL.
Abuse: nonce flooding
An attacker can send invalid proofs to trigger nonce generation and increase memory use. Mitigations:
Keep TTL short (e.g. 30 seconds).
Rate limit authentication-related endpoints.
Monitor nonce store size.
DPoP protects against token replay; it does not replace API rate limiting or input validation.
Verification
Run in dev mode
Start the application (leave this terminal running). Keycloak Dev Services will start a Keycloak container; ensure Docker (or Podman) is running.
./mvnw quarkus:devWait until the log shows Listening on:
http://localhost:8080
(or 8081 if 8080 is already in use) and that Keycloak is ready. Use the Quarkus port in the examples below.
End-to-end flow: how the balance endpoint is protected
Calling GET /accounts/{accountId}/balance does not work with a plain bearer token. The API requires DPoP: the client must obtain an access token from Keycloak by sending a DPoP proof to the token endpoint (so the token is bound to that key via cnf), then send that token and a new DPoP proof (same key pair) when calling the balance endpoint. The server may also require a nonce so that each proof is single-use. The flow looks like this:
So: Keycloak issues a token bound to your proof key; the balance endpoint only accepts that token when it is accompanied by a valid DPoP proof using the same key (and, when required, the server-issued nonce). You cannot call the balance endpoint with a different key or with a replayed proof.
Obtain a token from Keycloak with DPoP
To get an access token, call Keycloak’s token endpoint with a DPoP proof (and, for the resource-owner-password flow, grant_type=password, username, password, client_id=dpop-api). The token URL is {auth-server-url}/protocol/openid-connect/token (e.g. when using Dev Services, http://localhost:8180/realms/quarkus/protocol/openid-connect/token). Building the DPoP proof by hand with curl is cumbersome (you need a signed JWT with htm, htu, jwk, etc.). The next section uses an automated test that does the full flow.
Automated tests: Keycloak flow, binding, and invalid proof
The project includes tests that verify the Keycloak-based DPoP flow, token binding, and challenge path:
fullFlowKeycloakTokenThenBalanceWithDpopProofReturns200Generates a client key pair → builds a DPoP proof for the Keycloak token endpoint (POST, token URL, no
ath) → requests a token from Keycloak with that proof (password grant) → receives an access token withcnf→ calls the balance endpoint with the same key (proof includesath, then nonce after 401) → 200 and asserts the response body. This proves that the balance endpoint is reachable only when the token is Keycloak-issued, bound to the proof key, and presented with a valid DPoP proof and server-issued nonce.tokenWithWrongKeyReturns401Obtains a token from Keycloak with key pair A, then calls the balance endpoint with a DPoP proof signed with key pair B (same token). Expects 401 because the token’s
cnfdoes not match proof B. This demonstrates that the token is bound to the proof key.invalidProofReturns401WithChallengeSends an invalid DPoP proof (bad signature / wrong structure). Expects 401 and a
WWW-Authenticatechallenge.
Run all tests (Keycloak Dev Services will start a container; Docker/Podman required):
./mvnw testThe tests use DPoPProofBuilder (and the Nimbus JOSE library, test scope) to build proof JWTs: buildForKeycloak(...) for the token request (no ath, no nonce) and build(...) for the Quarkus API call (with ath, optional nonce). See BankAccountResourceTest and DPoPProofBuilder for the exact construction.
Conslusion
We built a Quarkus 3.32 REST API that:
Uses DPoP-bound access tokens and a custom nonce provider for replay protection.
Relies on Keycloak (Dev Services with DPoP enabled) to issue access tokens that are bound to the client’s proof key (
cnf). No custom login or local JWT signing; Quarkus OIDC verifies tokens and enforces DPoP binding.Protects a balance endpoint that accepts only tokens presented with a valid DPoP proof (same key as used at the token endpoint) and, when required, the server-issued nonce.
The DPoPNonceProvider SPI gives you control over nonce lifecycle; single-use enforcement prevents proof replay. In multi-node deployments, use a distributed store for nonces. The result is sender-constrained tokens with replay protection suitable for production scenarios.
DPoP without nonces protects against token theft; DPoP with nonces protects against proof replay.
Note: An alternative optimization for token signing (when you control the issuer) is to use a symmetric key, e.g. the OIDC client secret, provided it is at least 32 characters for strength. This demo uses Keycloak-issued tokens, so no local signing is involved.




Thanks for starting talking about how Quarkus can handle various proofs of token ownership such as DPoP, very important feature indeed, cheers