Docs

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.

Applies to:Apache APISIX 3.18.0

Roles:Platform engineerSecurity engineerTime:35 minutesImpact:No restart, no traffic change

Goal

An isolated OpenID Connect (OIDC) client and APISIX route that combine Pushed Authorization Requests (PAR), S256 Proof Key for Code Exchange (PKCE), DPoP-bound access tokens, and private_key_jwt client authentication without forwarding tokens, session cookies, or user information to the public sample upstream.

UpstreamKeycloakAPISIXBrowserUpstreamKeycloakAPISIXBrowserHold separate client-authenticationand DPoP key pairsRequest protected routePush authorization requestclient assertion, PKCE challenge, dpop_jktReturn request_uriRedirect with request_uriAuthenticate and authorizeRedirect with authorization code and stateSend authorization code and stateExchange code and PKCE verifierclient assertion and DPoP proofReturn DPoP access token with cnf.jktRequest user infoDPoP access token and new proof with athReturn user informationRedirect to requested resourcewith session cookieRequest protected resourcewith session cookieForward authenticated requestReturn responseReturn response
UpstreamKeycloakAPISIXBrowserUpstreamKeycloakAPISIXBrowserHold separate client-authenticationand DPoP key pairsRequest protected routePush authorization requestclient assertion, PKCE challenge, dpop_jktReturn request_uriRedirect with request_uriAuthenticate and authorizeRedirect with authorization code and stateSend authorization code and stateExchange code and PKCE verifierclient assertion and DPoP proofReturn DPoP access token with cnf.jktRequest user infoDPoP access token and new proof with athReturn user informationRedirect to requested resourcewith session cookieRequest protected resourcewith session cookieForward authenticated requestReturn responseReturn response

Before you begin

  • You run Apache APISIX 3.18.0 or later. PAR and DPoP support starts in APISIX 3.18.0; earlier versions cannot complete this guide.
  • You completed Set Up SSO with Keycloak from Configure Keycloak through Save the OIDC configuration. Keep Keycloak 26.7.3, quickstart-realm, quickstart-user, and KEYCLOAK_DISCOVERY; this guide creates a separate client so it does not break the earlier SSO route.
  • You have OpenSSL, Node.js 18 or later, cURL, and jq.
  • The directory oidc-keys, Keycloak client ID apisix-par-dpop-client, route ID oidc-par-dpop, and path /anything/par/* are unused.

Set the APISIX variables and verify the local targets before creating anything:

export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_URL="http://localhost:9080"
export PAR_CALLBACK_URL="${GATEWAY_URL}/anything/par/callback"
read -rsp "Admin API key: " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echo
read -rp "Keycloak discovery URL from the SSO guide: " KEYCLOAK_DISCOVERY && export KEYCLOAK_DISCOVERY

test ! -e oidc-keys

curl -s -o /dev/null -w '%{http_code}\n' \
  "${APISIX_ADMIN_URL}/apisix/admin/routes/oidc-par-dpop" \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}"

curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes" \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" | \
  jq -r '.list[] | select(.value.uri == "/anything/par/*") | .key'

Expected: test succeeds, the route lookup returns 404, and the path lookup has no output. If a target exists, stop and choose isolated names rather than overwriting it.

Steps

Three steps generate separate keys, create a dedicated Keycloak client, and create the APISIX route.

Step 1: Generate the signing keys

Use an RSA key and certificate for client authentication, and a separate EC P-256 key for DPoP:

umask 077
mkdir 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 30 \
  -subj "/CN=apisix-par-dpop-client"

KEYSTORE_PASSWORD="$(openssl rand -hex 24)" && export KEYSTORE_PASSWORD
openssl pkcs12 -export \
  -inkey oidc-keys/client-assertion-private.pem \
  -in oidc-keys/client-assertion.crt \
  -name apisix-par-dpop-client \
  -out oidc-keys/client-assertion.p12 \
  -passout "pass:${KEYSTORE_PASSWORD}"

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

Confirm that the private files are not group- or world-readable:

ls -l oidc-keys/client-assertion-private.pem \
  oidc-keys/client-assertion.p12 \
  oidc-keys/dpop-private.pem
jq -e '.kty == "EC" and .crv == "P-256" and (has("d") | not)' \
  oidc-keys/dpop-public-jwk.json

Expected: the private key and PKCS12 file modes begin with -rw-------, and jq returns true for a public JWK without a private d value.

What changed: the local directory contains separate restricted keys for client identity and DPoP token binding.

Step 2: Create the dedicated Keycloak client

In the quickstart-realm, select Clients → Create client, keep OpenID Connect, and enter apisix-par-dpop-client. Enable Client authentication and Standard flow, leave other authentication flows off, and continue.

Configure the client:

  1. Add the exact value of ${PAR_CALLBACK_URL}—http://localhost:9080/anything/par/callback for this guide—to Valid redirect URIs.
  2. Under Capability config, enable Require PKCE, select S256, enable Require DPoP bound tokens, and save.
  3. On Credentials, set Client Authenticator to Signed JWT and save.
  4. On Keys, select Import Certificate, choose PKCS12 as the archive format, enter the value printed by printf '%s\n' "${KEYSTORE_PASSWORD}" as the store password, and import oidc-keys/client-assertion.p12.

Expected: the separate client requires Standard flow, S256 PKCE, DPoP-bound tokens, and signed-JWT authentication; its only callback is the APISIX PAR path.

What changed: Keycloak will reject PAR and token requests that do not authenticate as the new client, and it will reject a token request without the matching DPoP proof.

Step 3: Create the protected route

Convert the key material to JSON values, set the dedicated client ID, and generate a fresh session secret:

export KEYCLOAK_CLIENT_ID="apisix-par-dpop-client"
OIDC_CLIENT_PRIVATE_KEY_JSON="$(jq -Rs . < oidc-keys/client-assertion-private.pem)" && export OIDC_CLIENT_PRIVATE_KEY_JSON
OIDC_DPOP_PRIVATE_KEY_JSON="$(jq -Rs . < oidc-keys/dpop-private.pem)" && export OIDC_DPOP_PRIVATE_KEY_JSON
OIDC_DPOP_PUBLIC_JWK_JSON="$(jq -c . < oidc-keys/dpop-public-jwk.json)" && export OIDC_DPOP_PUBLIC_JWK_JSON
APISIX_SESSION_SECRET="$(openssl rand -hex 32)" && export APISIX_SESSION_SECRET

Create the route. The JSON-valued key variables are intentionally unquoted in the heredoc because jq has already encoded them:

curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes/oidc-par-dpop" -X PUT \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<EOF
{
  "uri": "/anything/par/*",
  "plugins": {
    "openid-connect": {
      "client_id": "${KEYCLOAK_CLIENT_ID}",
      "discovery": "${KEYCLOAK_DISCOVERY}",
      "redirect_uri": "${PAR_CALLBACK_URL}",
      "bearer_only": false,
      "use_pkce": true,
      "accept_unsupported_alg": false,
      "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}
      },
      "scope": "openid profile email",
      "session": {"secret": "${APISIX_SESSION_SECRET}"},
      "set_access_token_header": false,
      "set_id_token_header": false,
      "set_userinfo_header": false
    },
    "proxy-rewrite": {
      "headers": {"remove": ["Authorization", "Cookie"]}
    }
  },
  "upstream": {
    "type": "roundrobin",
    "nodes": {"httpbin.org:80": 1}
  }
}
EOF
{ "value": { "id": "oidc-par-dpop", "uri": "/anything/par/*", "plugins": { "openid-connect": { "use_pkce": true, "par": { "enabled": true }, "dpop": { "enabled": true }, … } }, … } }

What changed: APISIX authenticates its PAR and token calls with the RSA key, proves DPoP possession with the separate EC key, keeps tokens inside its encrypted session, and strips browser credentials before calling the upstream.

Validate

Inspect the first redirect without following it:

curl -sS -D - -o /dev/null "${GATEWAY_URL}/anything/par/test" | \
  grep -i '^location:'

Expected: the Keycloak authorization URL contains a request_uri parameter. It does not expose the full authorization request parameters in the browser redirect because APISIX sent them to Keycloak's PAR endpoint over the back channel.

Open ${GATEWAY_URL}/anything/par/test in a private browser window and sign in as quickstart-user with password quickstart-user-pass.

Expected: Keycloak returns the browser to ${PAR_CALLBACK_URL}, APISIX successfully redeems the code, and httpbin returns a JSON response for /anything/par/test. Keycloak's enforced Signed JWT and DPoP settings make the successful PAR and token requests a positive check: a shared secret, missing DPoP proof, or mismatched DPoP key is rejected instead of being downgraded.

In the response's headers object, confirm that Authorization, Cookie, X-Access-Token, X-Id-Token, and X-Userinfo are absent. Reload the protected URL.

Expected: the reload succeeds through the APISIX session without another sign-in prompt, and no token or identity data is exposed to the public sample upstream.

Roll back / Clean up

Delete the route before deleting the Keycloak client or local keys:

curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/routes/oidc-par-dpop" \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}"

Expected: the Admin API returns a successful deletion response, and /anything/par/* no longer matches this route.

Delete only apisix-par-dpop-client from the quickstart-realm; leave the realm, user, and earlier apisix-quickstart-client intact if the basic Keycloak SSO guide still needs them. Remove the guide keys and unset secret-bearing variables:

rm -f oidc-keys/client-assertion-private.pem \
  oidc-keys/client-assertion.crt \
  oidc-keys/client-assertion.p12 \
  oidc-keys/dpop-private.pem \
  oidc-keys/dpop-public-jwk.json
rmdir oidc-keys
unset OIDC_CLIENT_PRIVATE_KEY_JSON OIDC_DPOP_PRIVATE_KEY_JSON \
  OIDC_DPOP_PUBLIC_JWK_JSON APISIX_SESSION_SECRET APISIX_ADMIN_KEY \
  KEYSTORE_PASSWORD

Troubleshoot

SymptomCauseFix
The first request fails before redirecting.Keycloak's discovery or PAR endpoint is unreachable, the imported certificate does not match the RSA key, or the client authenticator is not Signed JWT.Fetch ${KEYCLOAK_DISCOVERY} from the APISIX network, reimport the certificate, and verify the client's credential mode.
The redirect contains full request parameters instead of request_uri.PAR is disabled or the provider metadata has no usable PAR endpoint.Confirm par.enabled is true and inspect .pushed_authorization_request_endpoint in ${KEYCLOAK_DISCOVERY}.
Keycloak rejects the token request or returns a bearer token.DPoP is not enabled consistently, or the public and private EC keys do not match.Require DPoP-bound tokens on the client, keep dpop.enabled true, and regenerate the EC key pair as one unit.
APISIX reports an unsupported signing algorithm.The configured assertion or DPoP algorithm does not match the imported key type.Use RSA with RS256 for the client assertion and EC P-256 with ES256 for DPoP. Keep accept_unsupported_alg false.