| Tested on | Red Hat Enterprise Linux 10.2 (Coughlan) |
|---|---|
| Package | podman-5.8.2-5.el10_2.x86_64quay.io/keycloak/keycloak:26.7.3 |
| Applies to | Any Linux host with Podman or Docker |
| Privilege | Normal user for curl and browser steps; Keycloak admin access for client configuration |
| Lab environment | Keycloak 26.7.3 on http://127.0.0.1:8080/ — Install Keycloak with Podman |
| Scope | PAR client setup, PAR endpoint discovery, confidential-client authenticated push, request_uri browser handoff, PKCE inside the pushed request, random state/nonce, expiry and replay failures, and per-client PAR requirement. Does not cover full FAPI profiles, JWT-secured authorization requests (JAR), or a standalone PKCE tutorial. |
| Related guides | Keycloak PKCE tutorial Keycloak OpenID Connect tutorial Keycloak public vs confidential clients Keycloak clients explained Install Keycloak with Podman |
With PAR, your application sends the authorization parameters directly to Keycloak over a server-to-server HTTP POST instead of putting them in the browser URL. Keycloak returns a short-lived request_uri, and the browser carries only that reference to the authorization endpoint. This direct application-to-Keycloak request is often called the back channel. RFC 9126 Pushed Authorization Requests (PAR) keeps long query strings off the user agent and lets the authorization server authenticate the client before login starts.
This lab uses confidential clients par-lab-confidential and par-lab-required in realm golinuxcloud on Keycloak 26.7.3. You will:
- POST a code+PKCE authorization request to the PAR endpoint
- Open the returned
request_uriin a browser - Exchange the authorization code
- Reproduce expiry, replay, and PAR-required enforcement errors
Keycloak accepts PAR pushes only from authenticated confidential clients in this release.
What PAR changes
In a classic Authorization Code flow, the client builds a long /auth URL and redirects the browser with every parameter visible:
GET /auth?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...&code_challenge=...&code_challenge_method=S256With PAR, the client sends those parameters to a dedicated endpoint first. Keycloak stores them server-side and returns a reference:
POST /ext/par/request → { "request_uri": "urn:ietf:params:oauth:request_uri:...", "expires_in": 60 }
GET /auth?client_id=...&request_uri=urn:ietf:params:oauth:request_uri:...The browser URL stays short. PAR:
- Moves authorization parameters from the front channel to a direct client-to-Keycloak request
- Lets Keycloak authenticate the client before the browser reaches the authorization endpoint
- Validates pushed parameters such as
redirect_uri,scope, and PKCE before login starts
| Aspect | Ordinary authorization URL | PAR |
|---|---|---|
| Where parameters live | Entirely in the browser query string | Stored at the AS after back-channel POST |
| Client authentication at push | Not applicable | Required for confidential clients in Keycloak |
| Browser redirect carries | scope, redirect_uri, PKCE challenge, state, … |
client_id + request_uri only |
| Reference lifetime | N/A | Short-lived (expires_in seconds) |
| Replay of the same login attempt | New URL each time | Each request_uri is normally single-use |
PAR does not replace PKCE. You still:
- Generate
code_verifierandcode_challenge - Put them inside the PAR POST body
- Send the original verifier again at the token endpoint
PAR flow and security benefits
PAR helps when authorization requests are large, sensitive, or must be authenticated before the browser step:
- Shorter browser URLs — fewer bytes in history, referrer headers, and proxy logs
- Authenticated push — the AS knows which confidential client registered the request before showing a login form
- Short-lived, normally single-use reference — clients must use each
request_urifor one authorization attempt. Keycloak26.7.3rejected reuse after the completed login in this lab - Short lifetime — the reference expires quickly, so an old
request_uricannot be reused indefinitely
What PAR does not solve on its own:
- It does not bind tokens to a device key (see DPoP for sender-constrained tokens)
- It does not encrypt parameters end-to-end; use TLS on every hop
- It does not stop a user from approving a malicious client they already trust
Client/server Keycloak Browser
| | |
| POST PAR | |
|---------------------->| |
|<-- request_uri -------| |
| |
| redirect browser ----------------------------->|
| | |
| |<--- GET /auth ---------|
| | |
| |--- login page -------->|
| |<--- login ------------|
| | |
|<---- browser redirected with code ------------|
| |
| POST /token + code_verifier |
|---------------------->| |
|<---- tokens ----------| |Keycloak prerequisites
Confirm PAR is advertised for your realm before you create lab clients. OIDC discovery lists the endpoint and whether the realm mandates PAR globally (Keycloak defaults to optional):
curl -s http://127.0.0.1:8080/realms/golinuxcloud/.well-known/openid-configuration | \
python3 -c "import sys,json; d=json.load(sys.stdin); print('pushed_authorization_request_endpoint:', d['pushed_authorization_request_endpoint']); print('require_pushed_authorization_requests:', d['require_pushed_authorization_requests'])"Sample output:
pushed_authorization_request_endpoint: http://127.0.0.1:8080/realms/golinuxcloud/protocol/openid-connect/ext/par/request
require_pushed_authorization_requests: Falserequire_pushed_authorization_requests: False means PAR is available at the authorization-server level but not forced globally for every client.
Do not use this discovery value to decide whether a particular Keycloak client may skip PAR:
- A client can still have Pushed Authorization Request Required enabled even when discovery reports
require_pushed_authorization_requests: false - RFC 9126 allows PAR enforcement globally or on a per-client basis
Export shared lab variables:
export KC_BASE='http://127.0.0.1:8080'
export REALM='golinuxcloud'
export PAR_URL="${KC_BASE}/realms/${REALM}/protocol/openid-connect/ext/par/request"
export AUTH_URL="${KC_BASE}/realms/${REALM}/protocol/openid-connect/auth"
export TOKEN_URL="${KC_BASE}/realms/${REALM}/protocol/openid-connect/token"
export REDIRECT_URI='http://127.0.0.1:9100/callback'
export CLIENT_ID='par-lab-confidential'
export CLIENT_SECRET='paste-par-lab-confidential-secret'
export REQUIRED_CLIENT_ID='par-lab-required'
export REQUIRED_CLIENT_SECRET='paste-par-lab-required-secret'Set shell helpers for PKCE generation:
b64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
new_pkce() {
CODE_VERIFIER=$(openssl rand 32 | b64url)
CODE_CHALLENGE=$(
printf '%s' "$CODE_VERIFIER" |
openssl dgst -sha256 -binary |
b64url
)
}32 random bytes
|
| Base64URL
v
code_verifier
|
| SHA-256 + Base64URL
v
code_challengeConfigure the Keycloak PAR clients
Create two confidential OpenID Connect clients before the curl steps. Both use redirect URI http://127.0.0.1:9100/callback.
For par-lab-confidential:
- Client type: OpenID Connect
- Client authentication: ON
- Standard flow: ON
- Direct access grants: OFF
- Valid redirect URI:
http://127.0.0.1:9100/callback
Open Credentials → Client secret and copy the generated secret into CLIENT_SECRET.
For par-lab-required, use the same setup:
- Copy its secret into
REQUIRED_CLIENT_SECRET - Enable Pushed Authorization Request Required later when you demonstrate mandatory PAR
This walkthrough also expects:
- Realm
golinuxcloudwith test usertokenuser/TokenUser!2026 - Confidential clients — Keycloak rejects unauthenticated PAR pushes from public clients
Push the authorization request
Generate fresh PKCE material and unpredictable state / nonce values for this login attempt, then POST the full authorization request to the PAR endpoint:
- Include
client_idandclient_secretin the form body (client_secret_post) - Push
response_type,redirect_uri,scope,state,nonce, and PKCE fields together
new_pkce
STATE=$(openssl rand 16 | b64url)
NONCE=$(openssl rand 16 | b64url)RFC 9126 recommends unpredictable state, OIDC nonce, or PKCE to protect against request-URI substitution and replay.
curl -sS -w '\nHTTP:%{http_code}\n' -X POST "${PAR_URL}" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d 'response_type=code' \
-d "redirect_uri=${REDIRECT_URI}" \
-d 'scope=openid' \
-d "state=${STATE}" \
-d "nonce=${NONCE}" \
-d "code_challenge=${CODE_CHALLENGE}" \
-d 'code_challenge_method=S256'A successful push returns HTTP 201 with a URN-style reference and an expires_in lifetime (60 by default on Keycloak 26.7.3):
{"request_uri":"urn:ietf:params:oauth:request_uri:a05f2085-a44e-960f-64e6-a69145fafc4c","expires_in":60}
HTTP:201Copy the request_uri value into a shell variable for the next step:
export REQUEST_URI='urn:ietf:params:oauth:request_uri:a05f2085-a44e-960f-64e6-a69145fafc4c'Confidential client authentication is mandatory
Omit client_secret and Keycloak refuses the push before any browser step starts:
new_pkce
STATE=$(openssl rand 16 | b64url)
curl -sS -w '\nHTTP:%{http_code}\n' -X POST "${PAR_URL}" \
-d "client_id=${CLIENT_ID}" \
-d 'response_type=code' \
-d "redirect_uri=${REDIRECT_URI}" \
-d 'scope=openid' \
-d "state=${STATE}" \
-d "code_challenge=${CODE_CHALLENGE}" \
-d 'code_challenge_method=S256'{"error":"invalid_request","error_description":"Authentication failed."}
HTTP:401Public clients cannot push authorization requests in this Keycloak release. Plan PAR for server-side or otherwise confidential OIDC clients.
Use request_uri in the browser
Open the authorization endpoint with only client_id and the pushed reference. Do not repeat these in the browser URL — Keycloak reads them from the stored PAR object:
scoperedirect_uri- PKCE parameters
There is no application listening on port 9100 in this lab. After login:
- The browser may show a connection error when Keycloak redirects to the callback URL — that is expected
- Copy the
codeandstatevalues from the browser's address bar - Manually perform the application's token exchange in the next step
Real application This lab
| |
receives callback copy code from
automatically browser address bar
| |
POST /token POST /tokenpython3 -c "import urllib.parse; print(f'${AUTH_URL}?client_id=${CLIENT_ID}&request_uri=' + urllib.parse.quote('${REQUEST_URI}'))"Paste the printed URL into a browser (or hand it to your application's redirect). Keycloak responds with HTTP 302 to the login flow:
curl -sS -o /dev/null -w 'HTTP:%{http_code} redirect:%{redirect_url}\n' \
"${AUTH_URL}?client_id=${CLIENT_ID}&request_uri=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${REQUEST_URI}'))")"HTTP:302 redirect:http://127.0.0.1:8080/realms/golinuxcloud/login-actions/authenticate?client_id=par-lab-confidential&tab_id=...Sign in as tokenuser. After approval, Keycloak redirects to http://127.0.0.1:9100/callback with:
- An authorization
code - The
stateyou pushed
Confirm that the returned state exactly matches the value stored before the PAR request.
Exchange the code at the token endpoint. Reuse the same CODE_VERIFIER you generated before the PAR POST:
export AUTH_CODE='paste-code-from-callback'
curl -sS -X POST "${TOKEN_URL}" \
-d 'grant_type=authorization_code' \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "code=${AUTH_CODE}" \
-d "redirect_uri=${REDIRECT_URI}" \
-d "code_verifier=${CODE_VERIFIER}"{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile email"
}After a successful token exchange:
token_type: Bearerconfirms the PAR handoff did not change the token shape — only how the authorization request reached Keycloak- This lab does not decode the ID token because the focus is PAR
- In a real OIDC client, also validate that the ID token's
noncematches the value stored before the authorization request
Expiry, replay, and parameter integrity
Invalid or expired request_uri
References that never existed, or that outlived expires_in, return HTTP 400 with an error page titled Invalid Request:
curl -sS -o /tmp/par-bad.html -w 'HTTP:%{http_code}\n' \
"${AUTH_URL}?client_id=${CLIENT_ID}&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Adeadbeef-dead-beef-dead-beefdeadbeef"
grep -o 'Invalid Request' /tmp/par-bad.htmlHTTP:400
Invalid RequestPush a fresh request, wait past the expires_in value returned by your PAR response, then try the same request_uri again:
Keycloak's default PAR request URI lifetime is 60 seconds. For application code:
- Treat
expires_infrom the PAR response as authoritative - Do not hard-code
60— the realm setting isparRequestUriLifespan - Wait past the returned lifetime before expecting Invalid Request on reuse
new_pkce
STATE=$(openssl rand 16 | b64url)
PAR_JSON=$(curl -sS -X POST "${PAR_URL}" \
-d "client_id=${CLIENT_ID}" -d "client_secret=${CLIENT_SECRET}" \
-d 'response_type=code' -d "redirect_uri=${REDIRECT_URI}" \
-d 'scope=openid' -d "state=${STATE}" \
-d "code_challenge=${CODE_CHALLENGE}" -d 'code_challenge_method=S256')
export REQUEST_URI=$(echo "$PAR_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['request_uri'])")
export PAR_EXPIRES=$(echo "$PAR_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['expires_in'])")
echo "Waiting $((PAR_EXPIRES + 5)) seconds for expiry..."
sleep $((PAR_EXPIRES + 5))
curl -sS -o /tmp/par-expired.html -w 'HTTP:%{http_code}\n' \
"${AUTH_URL}?client_id=${CLIENT_ID}&request_uri=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${REQUEST_URI}'))")"
grep -o 'Invalid Request' /tmp/par-expired.htmlWaiting 65 seconds for expiry...
HTTP:400
Invalid RequestThe sleep duration uses expires_in + 5 from the PAR response. On Keycloak 26.7.3, the default expires_in is 60.
Replay after a successful login
Complete a login with a request_uri, then open the same authorization URL again. Keycloak shows Invalid Request instead of issuing a second code for the same reference.
RFC 9126 replay rules:
- Clients must use each
request_urionly once - Authorization servers normally treat it as one-time use
- Some servers may tolerate duplicate requests caused by browser refresh
Extra parameters on the authorization URL
RFC 9126 says clients must not send authorization parameters alongside request_uri. In this lab:
- Keycloak
26.7.3enforces expiry, replay, and unknown references strictly - Treat the browser URL as
client_id+request_urionly - Do not rely on the server ignoring extra query parameters
Require PAR for a Keycloak client
Enable PAR per client when a profile must never accept a long /auth query string:
- Pushed Authorization Request Required — per-client setting demonstrated in this lab
- Client Policies / FAPI profiles — separate mechanism that can impose additional PAR-related requirements
In the Admin Console, open Clients → par-lab-required → Advanced and enable Pushed Authorization Request Required.
When PAR is required, a classic authorization URL fails immediately — before the login form — with an OAuth error redirect:
new_pkce
STATE=$(openssl rand 16 | b64url)
curl -sS -o /dev/null -w '%{redirect_url}\n' \
"${AUTH_URL}?client_id=${REQUIRED_CLIENT_ID}&response_type=code&redirect_uri=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${REDIRECT_URI}'))")&scope=openid&state=${STATE}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256"http://127.0.0.1:9100/callback?error=invalid_request&error_description=Pushed+Authorization+Request+is+only+allowed.&state=...&iss=http%3A%2F%2F127.0.0.1%3A8080%2Frealms%2FgolinuxcloudThe fix is the same PAR POST you used earlier. Change only:
client_idtopar-lab-requiredclient_secrettopaste-par-lab-required-secret- The pushed PKCE,
state, andnoncevalues for the new attempt
PAR with PKCE and FAPI
Financial-grade API (FAPI) deployments often combine PAR, PKCE, and confidential clients. This article stops at the mechanics those profiles assume:
| Mechanism | Role with PAR |
|---|---|
| PKCE | Lives inside the PAR POST; verifier still required at /token |
| Confidential client | Authenticates the PAR push in Keycloak |
| PAR required flag | Blocks legacy long /auth URLs through the client's Pushed Authorization Request Required setting |
| FAPI client policies | Separate mechanism that can require PAR among other controls — not configured in this lab |
You already pushed code_challenge and code_challenge_method=S256 inside the PAR body. With PAR:
- Pushed authorization parameters stay out of the browser authorization URL
- The browser carries the short-lived
request_urireference instead
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Authentication failed on PAR POST (HTTP 401) |
Missing or wrong client_secret, or public client |
Use a confidential client; verify secret in Admin Console |
Invalid Request on /auth |
Expired, replayed, or unknown request_uri |
Push a new PAR; complete login within expires_in |
Pushed Authorization Request is only allowed on redirect |
Client has require.pushed.authorization.requests=true |
POST to PAR first; browser URL carries only request_uri |
Token endpoint invalid_grant after PAR login |
Wrong code_verifier or mismatched redirect_uri |
Reuse the verifier from the same PKCE pair that was pushed |
| PAR endpoint missing in discovery | Old Keycloak build or custom realm | Upgrade to a release with PAR support; check realm OIDC discovery |
Summary
Pushed Authorization Requests move OAuth authorization parameters off the browser and onto an authenticated back-channel POST. In Keycloak 26.7.3 you:
- POST code+PKCE fields to
/protocol/openid-connect/ext/par/requestas a confidential client - Receive a short-lived
request_uri - Send users to
/authwith only that reference - Finish with the same Authorization Code exchange you already know
PAR changes how the request arrives, not how tokens are shaped.
In practice, three details cause most PAR failures:
- The client must authenticate the push
request_uriexpires quickly- Clients configured to require PAR reject ordinary authorization URLs
Combine PAR with PKCE inside the push for modern OIDC clients, and enable Pushed Authorization Request Required when a profile must never accept front-channel parameter bundles.
For the wider OIDC path after PAR, continue with the OpenID Connect tutorial.
References
- RFC 9126 — OAuth 2.0 Pushed Authorization Requests
- Keycloak Server Administration Guide — OIDC clients
- OpenID Connect Discovery 1.0
Frequently Asked Questions
Does PAR replace PKCE?
No. PAR changes where authorization parameters are sent. PKCE still protects the authorization code at the token endpoint:
- Push
code_challengeandcode_challenge_methodinside the PAR POST - Send
code_verifierwhen you exchange the code
Is request_uri a normal URL?
No. Keycloak returns a URN such as urn:ietf:params:oauth:request_uri:…. You:
- Pass it as the
request_uriquery parameter on the authorization endpoint - Do not fetch it directly by the browser as an HTTP resource
How long does a pushed request_uri live?
Keycloak 26.7.3 returned expires_in: 60 in this lab. For application code:
- Honor the
expires_invalue returned by the PAR response - Remember the realm setting
parRequestUriLifespancontrols the default - Push again if the user pauses longer than the returned lifetime
After the window expires, the authorization endpoint responds with Invalid Request.
Must the client authenticate to use PAR?
Yes on Keycloak 26.7.3. Unauthenticated PAR posts returned Authentication failed with HTTP 401. Use:
- A confidential client
client_secret(or another supported client authentication method) in the PAR POST
When should PAR be required?
Require PAR on clients that must never accept front-channel authorization URLs — common in FAPI and high-assurance OIDC profiles:
- Enable Pushed Authorization Request Required on the client Advanced tab, or set
require.pushed.authorization.requeststotrue - Treat this as separate from the realm discovery field
require_pushed_authorization_requests - Keep PAR optional on ordinary clients that still accept classic
/authlinks

