Secure OIDC with PAR and DPoP
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 Save the OIDC Configuration. That page starts Keycloak 26.7.3 and creates the
quickstart-realm,apisix-quickstart-client,quickstart-user, andKEYCLOAK_DISCOVERYvalue reused here. - If you use the Admin API examples and authentication is enabled, export an admin-role key as
ADMIN_API_KEY. Configure the key underdeployment.admin.admin_key. The Docker quickstart disables authentication for local evaluation; keep it enabled in production.
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-par-dpop-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
The 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
Create a separate OpenID Connect client named apisix-par-dpop-client in the same Keycloak realm. Keeping it separate avoids changing the client used by the basic SSO example. Configure it to require the protections used in the APISIX route:
- In the Keycloak Admin Console, select Clients, create the OpenID Connect client
apisix-par-dpop-client, then open its Settings. - Under Valid redirect URIs, select Add valid redirect URIs and enter
http://localhost:9080/anything/par/callback. - In Capability config, turn on Client authentication and Require PKCE, keep Standard flow enabled, select S256 as the PKCE method, turn on Require DPoP bound tokens, and select Save.
- Open the Credentials tab. Set Client Authenticator to Signed JWT, then select Save.
- Open the Keys tab, select Import certificate, choose Certificate PEM, and import the public certificate
oidc-keys/client-assertion.crt. Keep the private key on the gateway side.
Keycloak advertises its PAR endpoint in the OIDC discovery document, so you do not need to configure a separate PAR endpoint in APISIX.
Set the dedicated client ID and generate a fresh session secret before choosing either configuration tab:
export KEYCLOAK_CLIENT_ID="apisix-par-dpop-client"
export APISIX_SESSION_SECRET="$(openssl rand -hex 32)"
Configure APISIX
Start a local echo upstream on the Docker quickstart network. The verification step sends user information only to this controlled container, not to a public service:
docker run -d \
--name oidc-dpop-verifier \
--network apisix-quickstart-net \
mccutchen/go-httpbin:v2.15.0
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.
- Admin API
- ADC
Convert the key files to JSON values that can be inserted into the Admin API request:
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/par/*",
"plugins": {
"openid-connect": {
"bearer_only": false,
"client_id": "$KEYCLOAK_CLIENT_ID",
"discovery": "$KEYCLOAK_DISCOVERY",
"scope": "openid profile email",
"redirect_uri": "http://localhost:9080/anything/par/callback",
"accept_unsupported_alg": false,
"set_access_token_header": false,
"set_id_token_header": false,
"set_userinfo_header": true,
"set_refresh_token_header": 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": "$APISIX_SESSION_SECRET"
}
},
"proxy-rewrite": {
"headers": {"remove": ["Authorization", "Cookie"]}
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"oidc-dpop-verifier:8080": 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: keycloak-par-dpop
routes:
- uris:
- /anything/par/*
name: oidc-par-dpop
plugins:
openid-connect:
bearer_only: false
client_id: ${KEYCLOAK_CLIENT_ID}
discovery: ${KEYCLOAK_DISCOVERY}
scope: openid profile email
redirect_uri: "http://localhost:9080/anything/par/callback"
accept_unsupported_alg: false
set_access_token_header: false
set_id_token_header: false
set_userinfo_header: true
set_refresh_token_header: 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: ${APISIX_SESSION_SECRET}
proxy-rewrite:
headers:
remove:
- Authorization
- Cookie
upstream:
type: roundrobin
nodes:
- host: oidc-dpop-verifier
port: 8080
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.
ADC reconciles services as desired state. The label selector limits this example to its own labeled resources. Preview the scoped changes and confirm that they contain no unintended updates or deletions:
adc diff -f adc.yaml \
--include-resource-type service \
--label-selector docs-example=keycloak-par-dpop
Synchronize the reviewed service configuration:
adc sync -f adc.yaml \
--include-resource-type service \
--label-selector docs-example=keycloak-par-dpop
Verify the OIDC Flow
Check the PAR redirect first, then complete sign-in and verify that credentials are not forwarded to the local verification upstream.
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/par/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-par-dpop-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.
Verify Sign-In and DPoP Enforcement
After the PAR redirect, open http://localhost:9080/anything/par/test in a private browser window. Sign in as the Keycloak test user. Keycloak returns an authorization code, APISIX exchanges it for tokens, and the authenticated request reaches the local echo upstream.
Verify a successful response. Its echoed headers should contain X-Userinfo, but no Authorization, Cookie, X-Access-Token, X-Id-Token, or X-Refresh-Token.
APISIX adds X-Userinfo only after Keycloak accepts the UserInfo request. Keycloak checks that APISIX's proof uses the key identified by the token's cnf.jkt claim and that the proof is bound to that access token. APISIX also rejects a token response whose token_type is not DPoP.
A user-info request that lacks the required proof or uses another key is rejected by Keycloak. This guide keeps access-token forwarding disabled and sends user information only to the local verification container. Set set_userinfo_header back to false unless a trusted upstream requires those claims. See the troubleshooting sections below for nonce and signing failures.
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.