API7 Docs
How-To GuidesAuthentication

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.

mR1eqav4nqjblight_UpstreammR1eqav4nqjblight_KeycloakmR1eqav4nqjblight_APISIXmR1eqav4nqjblight_BrowsermR1eqav4nqjblight_UpstreammR1eqav4nqjblight_KeycloakmR1eqav4nqjblight_APISIXmR1eqav4nqjblight_BrowserHold 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
mR1eqav4nqjbdark_UpstreammR1eqav4nqjbdark_KeycloakmR1eqav4nqjbdark_APISIXmR1eqav4nqjbdark_BrowsermR1eqav4nqjbdark_UpstreammR1eqav4nqjbdark_KeycloakmR1eqav4nqjbdark_APISIXmR1eqav4nqjbdark_BrowserHold 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

Prerequisite(s)

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`,
);
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

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:

  1. In the Keycloak Admin Console, select Clients > apisix-quickstart-client > Settings.
  2. In Capability config, keep Client authentication and Standard flow enabled. Turn on Require PKCE and Require DPoP bound tokens, then select Save.
  3. Open the Credentials tab. Set Client Authenticator to Signed JWT, then select Save.
  4. 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-client

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",
      "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.

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"

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");
EOF

You should receive a response similar to the following:

Configured key thumbprint: afk5TWQB3rBQgvRKUcw0xCv2pWfTYI5w3yYJcTjosEQ
Access token cnf.jkt:      afk5TWQB3rBQgvRKUcw0xCv2pWfTYI5w3yYJcTjosEQ
DPoP key binding verified

Matching 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.