Secure OIDC with PAR and DPoP
Configure APISIX and Keycloak to secure an OIDC authorization code flow with PAR, PKCE, DPoP-bound tokens, and private-key JWT authentication.
Pushed Authorization Requests (PAR) let APISIX send authorization request parameters directly to the identity provider. This keeps the parameters out of the browser redirect. Demonstrating Proof of Possession (DPoP) binds the access token APISIX receives to a key APISIX holds. APISIX sends a proof signed with that key when it calls the token endpoint and requests user information. The token endpoint requires the proof before issuing a DPoP-bound access token. A stolen access token cannot be replayed at the user-info endpoint without the key.
APISIX and Keycloak can enable PAR, DPoP, Proof Key for Code Exchange (PKCE), and private-key JWT independently, or together in one authorization code flow. With all four enabled, the openid-connect plugin authenticates to the Keycloak PAR and token endpoints with a signed client assertion instead of a shared client secret.
In this flow, APISIX is the OIDC client and holds the DPoP key. DPoP protects the access token that APISIX uses with Keycloak. It does not configure APISIX to validate DPoP proofs from external clients calling the protected route. Because this guide configures APISIX as a confidential client, Keycloak protects refresh tokens through client authentication rather than binding them to the DPoP key.
How the Flow Works
The private-key JWT and DPoP proofs use separate key pairs. The client-authentication key proves APISIX's identity at the PAR and token endpoints. The DPoP key binds the authorization code and access token to APISIX.
Prerequisite(s)
- Follow the Getting Started tutorial to start APISIX.
- Install OpenSSL, Node.js 18 or later, and jq.
- Install ADC if you use the ADC examples.
- Complete Set Up SSO with Keycloak from Configure Keycloak through Get Discovery Endpoint. That page starts Keycloak 26.7.1 and creates the
quickstart-realm,apisix-quickstart-client,quickstart-user, andOIDC_DISCOVERYvalue reused here.
Generate the Signing Keys
Use separate key pairs for client authentication and DPoP. Keycloak uses the client-authentication certificate to verify client assertions. APISIX uses the DPoP key to prove possession, and Keycloak binds the issued access token to its public-key thumbprint.
Create a working directory and generate an RSA private key and self-signed certificate for client authentication:
umask 077
mkdir -p oidc-keys
openssl genpkey -algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out oidc-keys/client-assertion-private.pem
openssl req -new -x509 \
-key oidc-keys/client-assertion-private.pem \
-out oidc-keys/client-assertion.crt \
-days 365 \
-subj "/CN=apisix-quickstart-client"Generate an EC P-256 key for DPoP and export its public key as a JWK:
node --input-type=module <<'EOF'
import { generateKeyPairSync } from "node:crypto";
import { writeFileSync } from "node:fs";
const { privateKey, publicKey } = generateKeyPairSync("ec", {
namedCurve: "P-256",
});
writeFileSync(
"oidc-keys/dpop-private.pem",
privateKey.export({ format: "pem", type: "pkcs8" }),
);
writeFileSync(
"oidc-keys/dpop-public-jwk.json",
`${JSON.stringify(publicKey.export({ format: "jwk" }), null, 2)}\n`,
);
EOFThe umask restricts the generated key files to the current user. Keep both private keys confidential. For production deployments, load private keys from an APISIX Secret instead of storing them directly in route configuration.
Configure the Keycloak Client
The Keycloak client created earlier still authenticates with a shared secret and leaves PKCE and DPoP optional. Update the same apisix-quickstart-client so Keycloak requires the protections used in the APISIX route:
- In the Keycloak Admin Console, select Clients > apisix-quickstart-client > Settings.
- In Capability config, keep Client authentication and Standard flow enabled. Turn on Require PKCE and Require DPoP bound tokens, then select Save.
- Open the Credentials tab. Set Client Authenticator to Signed JWT, then select Save.
- Open the Keys tab, select Import certificate, and import
oidc-keys/client-assertion.crt.
Keycloak advertises its PAR endpoint in the OIDC discovery document, so you do not need to configure a separate PAR endpoint in APISIX.
Configure APISIX
Set the OIDC client ID, then create a route with the openid-connect plugin. The route sets accept_unsupported_alg to false so APISIX rejects an ID token that uses an unsupported signing algorithm. Keycloak signs the ID tokens in this example with the supported RS256 algorithm.
export OIDC_CLIENT_ID=apisix-quickstart-clientExport the Admin API key and convert the key files to JSON values that can be inserted into the Admin API request:
export ADMIN_API_KEY="replace-with-your-admin-api-key"
export OIDC_CLIENT_PRIVATE_KEY_JSON="$(jq -Rs . < oidc-keys/client-assertion-private.pem)"
export OIDC_DPOP_PRIVATE_KEY_JSON="$(jq -Rs . < oidc-keys/dpop-private.pem)"
export OIDC_DPOP_PUBLIC_JWK_JSON="$(jq -c . < oidc-keys/dpop-public-jwk.json)"Create the route:
curl -i "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: $ADMIN_API_KEY" \
--data-binary @- <<EOF
{
"id": "oidc-par-dpop",
"uri": "/anything/*",
"plugins": {
"openid-connect": {
"bearer_only": false,
"client_id": "$OIDC_CLIENT_ID",
"discovery": "$OIDC_DISCOVERY",
"scope": "openid profile email",
"redirect_uri": "http://localhost:9080/anything/callback",
"accept_unsupported_alg": false,
"use_pkce": true,
"token_endpoint_auth_method": "private_key_jwt",
"client_rsa_private_key": $OIDC_CLIENT_PRIVATE_KEY_JSON,
"client_jwt_assertion_alg": "RS256",
"par": {
"enabled": true,
"endpoint_auth_method": "private_key_jwt"
},
"dpop": {
"enabled": true,
"signing_alg": "ES256",
"private_key": $OIDC_DPOP_PRIVATE_KEY_JSON,
"public_jwk": $OIDC_DPOP_PUBLIC_JWK_JSON
},
"session": {
"secret": "f86cf31663a9c9fa0a28c2cc78badef1"
}
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"httpbin.org:80": 1
}
}
}
EOF❶ use_pkce: Set to true to send an S256 PKCE challenge during authorization.
❷ token_endpoint_auth_method: Set to private_key_jwt. APISIX signs token-endpoint client assertions with client_rsa_private_key.
❸ client_jwt_assertion_alg: Must match the key type and an algorithm accepted by Keycloak. The RSA key generated above uses RS256.
❹ par: Set enabled to true and endpoint_auth_method to private_key_jwt. APISIX sends authorization parameters to the PAR endpoint from the discovery document, and the browser redirect contains the resulting request_uri.
❺ dpop: Set enabled to true to send DPoP proofs to the token endpoint and when APISIX requests user information. public_jwk must match private_key and must not contain private key fields.
Load the PEM files into environment variables, then print the DPoP public JWK to copy into dpop.public_jwk:
export OIDC_CLIENT_PRIVATE_KEY="$(cat oidc-keys/client-assertion-private.pem)"
export OIDC_DPOP_PRIVATE_KEY="$(cat oidc-keys/dpop-private.pem)"
jq '{kty, crv, x, y}' oidc-keys/dpop-public-jwk.jsonCreate the route:
services:
- name: httpbin Service
routes:
- uris:
- /anything/*
name: oidc-par-dpop
plugins:
openid-connect:
bearer_only: false
client_id: ${OIDC_CLIENT_ID}
discovery: ${OIDC_DISCOVERY}
scope: openid profile email
redirect_uri: "http://localhost:9080/anything/callback"
accept_unsupported_alg: false
use_pkce: true
token_endpoint_auth_method: private_key_jwt
client_rsa_private_key: ${OIDC_CLIENT_PRIVATE_KEY}
client_jwt_assertion_alg: RS256
par:
enabled: true
endpoint_auth_method: private_key_jwt
dpop:
enabled: true
signing_alg: ES256
private_key: ${OIDC_DPOP_PRIVATE_KEY}
public_jwk:
kty: EC
crv: P-256
x: replace-with-x
y: replace-with-y
session:
secret: "f86cf31663a9c9fa0a28c2cc78badef1"
upstream:
type: roundrobin
nodes:
- host: httpbin.org
port: 80
weight: 1❶ use_pkce: Set to true to send an S256 PKCE challenge during authorization.
❷ token_endpoint_auth_method: Set to private_key_jwt. APISIX signs token-endpoint client assertions with client_rsa_private_key.
❸ client_jwt_assertion_alg: Must match the key type and an algorithm accepted by Keycloak. The RSA key generated above uses RS256.
❹ par: Set enabled to true and endpoint_auth_method to private_key_jwt. APISIX sends authorization parameters to the PAR endpoint from the discovery document, and the browser redirect contains the resulting request_uri.
❺ dpop: Set enabled to true to send DPoP proofs to the token endpoint and when APISIX requests user information. public_jwk must match private_key and must not contain private key fields. Paste the printed JWK fields into public_jwk.
Synchronize the configuration to APISIX:
adc sync -f adc.yamlVerify the OIDC Flow
Check the PAR redirect first, then complete the browser sign-in and inspect the DPoP confirmation claim on the access token.
Verify the PAR Redirect
Request the protected route without following redirects so you can inspect the Location header:
curl -sS -D - -o /dev/null "http://localhost:9080/anything/test"You should receive an HTTP/1.1 302 response. The Location header should contain request_uri, similar to the following:
Location: http://192.168.42.145:8080/realms/quickstart-realm/protocol/openid-connect/auth?client_id=apisix-quickstart-client&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A...The presence of request_uri confirms that APISIX pushed the authorization request to Keycloak instead of placing the complete request parameters in the browser redirect.
Sign In and Verify the DPoP-Bound Token
After the PAR redirect, open http://localhost:9080/anything/test in a private browser window. Sign in with the username quickstart-user and password quickstart-user-pass. Keycloak returns an authorization code, APISIX exchanges it for tokens, and the request is forwarded to httpbin.org. You should receive an HTTP/1.1 200 OK response containing request details. Verify that the JSON response contains X-Access-Token and X-Userinfo under headers. Copy the value of X-Access-Token, then export it:
export ACCESS_TOKEN="replace-with-the-x-access-token-value"Calculate the RFC 7638 thumbprint of the configured public JWK and compare it with the access token's confirmation claim:
node --input-type=module <<'EOF'
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const jwk = JSON.parse(readFileSync("oidc-keys/dpop-public-jwk.json", "utf8"));
const canonicalJwk = JSON.stringify({
crv: jwk.crv,
kty: jwk.kty,
x: jwk.x,
y: jwk.y,
});
const expected = createHash("sha256")
.update(canonicalJwk)
.digest("base64url");
const payload = process.env.ACCESS_TOKEN.split(".")[1];
const claims = JSON.parse(Buffer.from(payload, "base64url"));
const actual = claims.cnf?.jkt;
console.log(`Configured key thumbprint: ${expected}`);
console.log(`Access token cnf.jkt: ${actual}`);
if (!actual || actual !== expected) {
throw new Error("The access token is not bound to the configured DPoP key");
}
console.log("DPoP key binding verified");
EOFYou should receive a response similar to the following:
Configured key thumbprint: afk5TWQB3rBQgvRKUcw0xCv2pWfTYI5w3yYJcTjosEQ
Access token cnf.jkt: afk5TWQB3rBQgvRKUcw0xCv2pWfTYI5w3yYJcTjosEQ
DPoP key binding verifiedMatching thumbprints confirm that Keycloak bound the access token to the DPoP key configured in APISIX. The X-Userinfo value confirms that APISIX used a valid DPoP proof when it requested user information from Keycloak. A user-info request to Keycloak that omits the proof or uses a different key returns 401 Unauthorized. APISIX also rejects a token response whose token_type is not DPoP, so the completed flow confirms that Keycloak did not downgrade the access token to a bearer token.
Troubleshoot DPoP
Token Endpoint Returns a Bearer Token
If the APISIX error log contains token endpoint returned an access token without token_type DPoP, verify that Require DPoP bound tokens is enabled for the Keycloak client. When DPoP is enabled, APISIX rejects a token response that does not declare token_type as DPoP.
Identity Provider Rejects the Proof
When the discovery document lists dpop_signing_alg_values_supported, APISIX checks that it includes dpop.signing_alg. If the provider rejects a proof, verify that APISIX and the provider have synchronized clocks. Also verify that APISIX reaches the token and user-info endpoints at the URLs advertised in the discovery document. DPoP proofs are bound to the request method, endpoint URL, and creation time.
An identity provider can require a fresh nonce to prevent proof replay. APISIX automatically creates a new proof and retries the token request once after a 400 or 401 response with a DPoP-Nonce header. It also retries the user-info request once after a 401 response with that header. APISIX does not retry a second time; check its error log for the provider response.
Prepare for Production
The local Keycloak quickstart uses HTTP. In production, use HTTPS for the identity provider and keep ssl_verify enabled so APISIX verifies its certificate. DPoP sender-constrains the access token, but it does not provide transport confidentiality or replace server-authenticated TLS.
Store the DPoP private key in a secret manager and limit access to it. When rotating the key, remember that existing access tokens retain the thumbprint of the previous public key. Coordinate rotation with the access-token lifetime and your session renewal policy rather than assuming that existing tokens are rebound to the new key.
Next Steps
The openid-connect plugin reference covers the remaining PAR, DPoP, and client-assertion options.