Secure OIDC with PAR and DPoP
Pushed Authorization Requests (PAR) let APISIX send authorization request parameters directly to the identity provider instead of exposing them in the browser redirect. Demonstrating Proof of Possession (DPoP) binds the access and refresh tokens APISIX presents to the identity provider to a key APISIX holds. APISIX sends that proof when it calls the token endpoint and when it requests user information, so a stolen token cannot be replayed to those endpoints 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.
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 tokens to its public-key thumbprint.
Create a working directory and generate an RSA private key and self-signed certificate for client authentication:
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`,
);
EOF
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:
export OIDC_CLIENT_ID=apisix-quickstart-client
- Admin API
- ADC
Export 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",
"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.json
Create 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"
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.yaml
Verify 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"
Decode the access token payload and print its confirmation claim:
node -e '
const payload = process.env.ACCESS_TOKEN.split(".")[1];
const claims = JSON.parse(Buffer.from(payload, "base64url"));
console.log(JSON.stringify(claims.cnf, null, 2));
'
You should receive a response similar to the following:
{
"jkt": "afk5TWQB3rBQgvRKUcw0xCv2pWfTYI5w3yYJcTjosEQ",
"kc-jkt-type": "DPoP"
}
The jkt confirmation claim is the thumbprint of the DPoP public key. Its presence confirms that Keycloak bound the access token to the DPoP key held by APISIX. Keycloak may also include kc-jkt-type; that field is Keycloak-specific. The X-Userinfo value confirms that APISIX used a valid DPoP proof when it requested user information from Keycloak. A user-info request that omits the proof or uses a different key returns 401 Unauthorized.
Next Steps
The openid-connect plugin reference covers the remaining PAR, DPoP, and client-assertion options. For production deployments, store private keys in an APISIX Secret.