Introduction
In this article, we demonstrate how to access the Admin REST API of Keycloak using DPoP-bound tokens with a confidential client.
We will:
- Enable Require DPoP bound tokens
- Use Client Credentials flow
- Generate DPoP proof using Node.js
- Call the Admin API to list users
This example uses the realm acme and a confidential client kc-admin-client and demonstrate Keycloak API admin access to list first five users. (role used realm-management → view-users)
Why DPOP?
Normally, OAuth2 access tokens are Bearer tokens.
If stolen, anyone can use them.
DPoP (Demonstration of Proof-of-Possession):
- Cryptographically binds the access token to a private key
- Requires a signed proof per request
- Prevents replay attacks
When enabled in Keycloak:
The token can only be used by the client that possesses the private key.
For more details on Demonstrating Proof-of-Possession at the Application Layer (DPoP please refer the link.
Implementation Steps
Step 1: Create an OIDC client In Keycloak with DPoP enabled
- Create confidential OIDC client with Client Credentials enabled (I gave the name kc-admin-client for this client)
- Client Authentication = ON
- Service Accounts Enabled = ON
- Enable Require DPoP bound tokens = ON
- From Service Account Roles of the client settings, assign client role view-users (realm-management → view-users)
The role realm-management – view-users is sufficient to list users (which we are targeting for the admin API call)
Step 2: Node.js script to handle DPoP
Instead of manually crafting JWK and signing JWTs, we let Node.js do everything.
Install dependencies:
Now create a javascript file with below content (dpop-admin-viewusers.js)
import { generateKeyPair, exportJWK, SignJWT } from "jose";
import crypto from "crypto";
const KEYCLOAK_BASE = "your_keycloak_hostname";
const REALM = "acme";
const CLIENT_ID = "kc-admin-client";
const CLIENT_SECRET = "your_client_secret";
// Reuse a single key pair across all requests.
// Generate once at startup — the key is stable, only the DPoP proof JWT is ephemeral.
const { publicKey, privateKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);
async function main() {
// ── Step 1: Obtain DPoP-bound access token ──────────────────────────────────
const tokenUrl = new URL(
`/realms/${REALM}/protocol/openid-connect/token`,
KEYCLOAK_BASE
).toString();
// No `ath` at token endpoint — there is no access token yet at this stage.
const dpopForToken = await createDpopProof(privateKey, jwk, tokenUrl, "POST");
const tokenResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"DPoP": dpopForToken,
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
// Handle DPoP-Nonce challenge from the token endpoint.
// If Keycloak returns `use_dpop_nonce`, retry once with the server-supplied nonce.
if (!tokenResponse.ok) {
const errorJson = await tokenResponse.json();
if (errorJson.error === "use_dpop_nonce") {
const serverNonce = tokenResponse.headers.get("DPoP-Nonce");
console.log("Server requested DPoP nonce — retrying with nonce:", serverNonce);
const dpopForTokenWithNonce = await createDpopProof(
privateKey, jwk, tokenUrl, "POST", null, serverNonce
);
const retryResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"DPoP": dpopForTokenWithNonce,
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!retryResponse.ok) {
console.error("Token error after nonce retry:", await retryResponse.json());
return;
}
return await callAdminApi(await retryResponse.json(), serverNonce);
}
console.error("Token error:", errorJson);
return;
}
return await callAdminApi(await tokenResponse.json(), null);
}
async function callAdminApi(tokenJson, dpopNonce) {
const accessToken = tokenJson.access_token;
console.log("DPoP-bound token obtained");
// ── Step 2: Call Admin API ────────────────────────────────────────────────
const usersUrlString = `${KEYCLOAK_BASE}/admin/realms/${REALM}/users?first=0&max=5`;
console.log("Users URL:", usersUrlString);
// Strip query string from `htu` (RFC 9449 §4.2).
// `htu` must be the URL without query string or fragment.
// The actual fetch still uses the full URL with query params.
const htu = (() => {
const u = new URL(usersUrlString);
return `${u.origin}${u.pathname}`;
})();
// `ath` is required when presenting an access token to a resource server (RFC 9449 §4.2).
// It is the base64url-encoded SHA-256 hash of the ASCII access token string.
const ath = crypto
.createHash("sha256")
.update(accessToken)
.digest("base64url");
const dpopForUsers = await createDpopProof(
privateKey, jwk, htu, "GET", ath, dpopNonce
);
const usersResponse = await fetch(usersUrlString, {
method: "GET",
headers: {
// Must use `DPoP` scheme — NOT `Bearer` — when DPoP is required.
"Authorization": `DPoP ${accessToken}`,
"DPoP": dpopForUsers,
},
});
// Handle DPoP-Nonce challenge from the resource server too.
if (!usersResponse.ok) {
const errorText = await usersResponse.text();
const newNonce = usersResponse.headers.get("DPoP-Nonce");
if (newNonce) {
console.log("Resource server requested DPoP nonce — retrying:", newNonce);
const dpopRetry = await createDpopProof(
privateKey, jwk, htu, "GET", ath, newNonce
);
const retryUsers = await fetch(usersUrlString, {
method: "GET",
headers: {
"Authorization": `DPoP ${accessToken}`,
"DPoP": dpopRetry,
},
});
if (!retryUsers.ok) {
console.error("Users API error after nonce retry:", await retryUsers.text());
return;
}
console.log("Users:", await retryUsers.json());
return;
}
console.error("Users API error:", errorText);
return;
}
const users = await usersResponse.json();
console.log("Users:", users);
}
/**
* Creates a DPoP proof JWT.
*
* @param {CryptoKey} privateKey - ES256 private key
* @param {object} jwk - Corresponding public JWK (embedded in header)
* @param {string} htu - HTTP URI — MUST NOT include query string or fragment (RFC 9449 §4.2)
* @param {string} htm - HTTP method (GET, POST, …)
* @param {string|null} ath - base64url SHA-256 of access token; required at resource server, omit at token endpoint
* @param {string|null} nonce - Server-supplied DPoP nonce (include when server requests it)
*/
async function createDpopProof(privateKey, jwk, htu, htm, ath = null, nonce = null) {
const payload = { htm, htu };
// `ath` binds the proof to a specific access token — mandatory at resource servers.
if (ath) payload.ath = ath;
// `nonce` prevents replay attacks when the server opts in to nonce-based replay protection.
if (nonce) payload.nonce = nonce;
return await new SignJWT(payload)
.setProtectedHeader({
alg: "ES256",
typ: "dpop+jwt",
jwk, // Public key so the server can verify the signature
})
.setIssuedAt() // `iat` — Keycloak rejects proofs outside its clock-skew window (~5 s default)
.setJti(crypto.randomUUID()) // `jti` — unique per proof, enables replay detection
.sign(privateKey);
}
main().catch(console.error);
Make sure to substitute KEYCLOAK_BASE and CLIENT_SECRET place holders with correct values in your code.
Important Differences From Bearer Flow
Notice the Admin API call:
Authorization: DPoP <access_token>
DPoP: <signed_proof>
NOT:
Authorization: Bearer
If you use Bearer — you will get: 401 Unauthorized (because DPoP is mandatory.)
When the Admin API is called:
- Keycloak extracts JWK from DPoP header
- Validates ES256 signature
- Computes JWK thumbprint
- Verifies it matches
cnf.jktinside access token - Performs additional check..(Keycloak also verifies
htmmatches the HTTP method andiatis within an acceptable clock skew window)
Testing the implementation
Call the node js file
node dpop-admin-viewusers.js
You should see the details of first 5 realm users listed.
Production Considerations
For production:
- Store private key securely
- Consider hardware-backed keys
- Enable TLS always
- Monitor replay detection logs
Summary
We have in this article found how to make use Demonstrating Proof-of-Possession (DPoP) bound tokens.
DPoP is especially useful for:
- Public clients
- Mobile apps
- SPA + API architectures
- Zero Trust environments
It significantly reduces token replay risk.
About Skycloak
If you’re new to Skycloak, visit the Skycloak Getting Started Guide to learn more and securing your Keycloak deployments.
Skycloak is a fully managed Keycloak platform hosted in the cloud. It enables organizations to leverage the power of open-source Keycloak IAM without the operational overhead of installing, maintaining, and scaling production-grade Keycloak environments—delivered securely and cost-effectively.