OAuth2 Pushed Authorization Requests (PAR) — RFC 9126 for Secure Authorization Request Handling
In this tutorial, you will learn about OAuth2 Pushed Authorization Requests (PAR). We cover key concepts, practical examples, and best practices to help you master this topic.
OAuth2 Pushed Authorization Requests (PAR, RFC 9126) allow clients to POST authorization request parameters directly to the authorization server, returning a request URI that replaces the traditional query-string authorization request, preventing URL length limits and request tampering.
What You'll Learn
- PAR flow and request endpoint
- Benefits over query-string authorization requests
- Request URI lifecycle and security
- PAR with PKCE for defense in depth
- Implementing PAR in authorization servers and clients
Why It Matters
Traditional authorization requests place all parameters in the URL query string, which has length limits (browser URL bars, reverse proxies) and exposes parameters to browser history and referrer headers. PAR solves both problems. DodaTech's authorization server adopted PAR in 2024, eliminating a class of authorization request tampering attacks.
Real-World Use
A mobile app needs to request 15 scopes with a complex PKCE challenge and redirect URI. The URL exceeds browser length limits. Using PAR, the app POSTs the parameters directly to the server and receives a short request_uri that fits in any browser without truncation or tampering.
sequenceDiagram
participant Client
participant Auth as Authorization Server
participant Browser
Client->>Auth: POST /as/par (client_id, redirect_uri, scope, code_challenge, state)
Auth->>Auth: Validate and store params
Auth-->>Client: request_uri = "urn:ietf:params:oauth:request_uri:abc123"
Client->>Browser: Redirect to /authorize?client_id&request_uri=abc123
Browser->>Auth: GET /authorize (request_uri)
Auth->>Auth: Look up stored params
Auth-->>Browser: Consent screen
Browser-->>Client: Authorization code
Code Examples
Example 1: PAR Endpoint
from flask import Flask, request, jsonify
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
app = Flask(__name__)
# In-memory store for PAR requests (use Redis in production)
par_store = {}
@app.route('/as/par', methods=['POST'])
def pushed_authorization_request():
"""RFC 9126 Pushed Authorization Request endpoint."""
client_id = request.form.get('client_id')
redirect_uri = request.form.get('redirect_uri')
scope = request.form.get('scope')
state = request.form.get('state')
code_challenge = request.form.get('code_challenge')
code_challenge_method = request.form.get('code_challenge_method', 'S256')
# Validate client
client = get_client(client_id)
if not client:
return jsonify({'error': 'invalid_client'}), 401
# Validate redirect URI
if redirect_uri not in client['redirect_uris']:
return jsonify({'error': 'invalid_redirect_uri'}), 400
# Validate scopes
if scope:
requested_scopes = scope.split()
allowed_scopes = client.get('scopes', [])
invalid = [s for s in requested_scopes if s not in allowed_scopes]
if invalid:
return jsonify({'error': 'invalid_scope'}), 400
# Generate unique request URI
request_uri = f"urn:ietf:params:oauth:request_uri:{secrets.token_urlsafe(16)}"
# Store the request parameters with expiry
par_store[request_uri] = {
'client_id': client_id,
'redirect_uri': redirect_uri,
'scope': scope,
'state': state,
'code_challenge': code_challenge,
'code_challenge_method': code_challenge_method,
'expires_at': datetime.now(timezone.utc) + timedelta(seconds=60)
}
print(f"PAR stored: {request_uri} for client {client_id}")
return jsonify({
'request_uri': request_uri,
'expires_in': 60
}), 201
Example 2: Authorization Endpoint with PAR Support
@app.route('/authorize', methods=['GET'])
def authorize_with_par():
"""Authorization endpoint supporting both PAR and standard requests."""
request_uri = request.args.get('request_uri')
if request_uri:
# PAR flow: resolve stored parameters
stored = par_store.get(request_uri)
if not stored:
return render_error('invalid_request_uri',
'Request URI not found or expired')
if stored['expires_at'] < datetime.now(timezone.utc):
del par_store[request_uri]
return render_error('expired_request_uri',
'Request URI has expired (max 60 seconds)')
# Use stored parameters
client_id = stored['client_id']
redirect_uri = stored['redirect_uri']
scope = stored['scope']
state = stored['state']
code_challenge = stored.get('code_challenge')
code_challenge_method = stored.get('code_challenge_method')
# Delete from store after use (single-use)
del par_store[request_uri]
else:
# Standard query-string flow
client_id = request.args.get('client_id')
redirect_uri = request.args.get('redirect_uri')
scope = request.args.get('scope')
state = request.args.get('state')
code_challenge = request.args.get('code_challenge')
# Continue with standard authorization flow...
user = authenticate_user(request)
if not user:
return redirect_to_login(request.url)
return show_consent_screen(client_id, redirect_uri, scope, state,
code_challenge)
Example 3: Client-Side PAR Implementation
import requests
class PARClient:
def __init__(self, client_id, client_secret, auth_server):
self.client_id = client_id
self.client_secret = client_secret
self.auth_server = auth_server
self.par_endpoint = f"{auth_server}/as/par"
self.authorize_endpoint = f"{auth_server}/authorize"
def start_authorization(self, redirect_uri, scope, state=None,
code_challenge=None):
"""Push authorization request via PAR, then build authorize URL."""
params = {
'client_id': self.client_id,
'redirect_uri': redirect_uri,
'scope': scope,
'state': state or secrets.token_urlsafe(16)
}
if code_challenge:
params['code_challenge'] = code_challenge
params['code_challenge_method'] = 'S256'
# Push to PAR endpoint
response = requests.post(
self.par_endpoint,
data=params,
auth=(self.client_id, self.client_secret)
)
if response.status_code != 201:
raise Exception(f"PAR failed: {response.json()}")
par_result = response.json()
request_uri = par_result['request_uri']
# Build minimal authorize URL
authorize_url = (
f"{self.authorize_endpoint}"
f"?response_type=code"
f"&client_id={self.client_id}"
f"&request_uri={request_uri}"
)
return authorize_url, params['state']
def exchange_code(self, code, redirect_uri, code_verifier=None):
"""Exchange authorization code for tokens."""
params = {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
'client_id': self.client_id
}
if code_verifier:
params['code_verifier'] = code_verifier
response = requests.post(
f"{self.auth_server}/token",
data=params,
auth=(self.client_id, self.client_secret)
)
return response.json()
# Usage
client = PARClient(CLIENT_ID, CLIENT_SECRET, 'https://auth.dodatech.com')
# Generate PKCE challenge
code_verifier = secrets.token_urlsafe(32)
code_challenge = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(code_challenge).rstrip('=').decode()
# Start authorization via PAR
auth_url, state = client.start_authorization(
redirect_uri='https://app.dodatech.com/callback',
scope='read:threats write:remediation',
code_challenge=code_challenge
)
print(f"Redirect user to: {auth_url}")
Common Mistakes
1. Creating Request URIs Without Client Authentication
The PAR endpoint must authenticate the client. Otherwise, anyone can push authorization requests.
2. Long Request URI Expiry
PAR request URIs must expire within seconds (max 60), not minutes or hours. Short expiry prevents replay.
3. Reusing Request URIs
Request URIs are single-use. After the authorization endpoint resolves them, delete them from the store.
4. Not Validating Parameters at the PAR Endpoint
Validate all parameters (redirect URI, scopes, client) at the PAR endpoint, not just at the authorize endpoint.
5. Mixing PAR and Non-PAR Parameters
When a request_uri is present, ignore all other query parameters. Don't merge them.
Practice Questions
- What problem does PAR solve?
- How long should a request URI be valid?
- Why must the client authenticate at the PAR endpoint?
- What happens when a request URI expires?
- Can PAR be combined with PKCE?
Answers:
- URL length limits on authorization requests and exposure of parameters in browser history/referrer headers.
- Maximum 60 seconds, typically 5-10 seconds. Short enough to prevent replay.
- Otherwise anyone could push a malicious authorization request associated with your client_id.
- The authorization endpoint returns an
expired_request_urierror, and the client must push a new PAR request. - Yes. PAR and PKCE are complementary — PAR protects request integrity; PKCE protects the authorization code exchange.
Challenge: Implement PAR for an existing OAuth2 authorization server. Add a PAR endpoint, modify the authorization endpoint to resolve request URIs, and build a client that uses PAR with PKCE.
FAQ
What's Next
Combine PAR with {{< ilink "OAuth" "PKCE Extension" }} for a complete secure authorization flow, and review {{< ilink "OAuth" "OAuth2 Redirect URIs" }} for redirect URI security.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro