Skip to main content
Version: 3.19.0

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, and KEYCLOAK_DISCOVERY value 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 under deployment.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:

  1. In the Keycloak Admin Console, select Clients, create the OpenID Connect client apisix-par-dpop-client, then open its Settings.
  2. Under Valid redirect URIs, select Add valid redirect URIs and enter http://localhost:9080/anything/par/callback.
  3. 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.
  4. Open the Credentials tab. Set Client Authenticator to Signed JWT, then select Save.
  5. 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.

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.

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.