PAR (Pushed Authorization Requests): keeping your OAuth parameters off the URL

Standard OAuth 2.0 authorization requests put all parameters in the URL: client ID, redirect URI, scope, state, PKCE challenge. The browser navigates to this URL, which means these parameters are visible in browser history, server access logs, HTTP Referer headers, and intermediary caches. For most applications this is acceptable. For financial-grade applications, the leakage of the state parameter (which contains a CSRF token) or the ability to tamper with redirect URIs in transit is a risk that Pushed Authorization Requests (PAR) eliminates. RFC 9126 standardizes the mechanism.

The problem with query string parameters

When a user clicks "Sign in with OAuth" and their browser navigates to the authorization endpoint, the full URL including all parameters ends up in:

  • Browser history — any other user of the same browser can see the URL
  • Server access logs at every reverse proxy and load balancer in the path
  • HTTP Referer header if the authorization page contains any external resources (analytics, fonts)
  • Network monitoring appliances in corporate environments

The state parameter is a CSRF token. If it leaks, an attacker who observes it can complete a CSRF attack against the user's authorization. The redirect URI can potentially be manipulated in transit if the authorization request is not authenticated. PAR solves both by moving the parameters out of the URL before the browser ever navigates.

The PAR flow

In a PAR flow, the client makes a server-to-server POST request to the authorization server's pushed authorization request endpoint. This request contains all the standard OAuth parameters, plus client authentication credentials. The server stores the parameters and returns a request_uri — a short-lived reference to the stored request. The client then redirects the browser to the authorization endpoint with only the request_uri and client_id, revealing nothing else in the URL.

// Step 1: Push the authorization request parameters server-side
async function initiateOAuthWithPAR(
  userId: string,
  scopes: string[]
): Promise<{ redirectUrl: string; state: string }> {
  const state = crypto.randomBytes(32).toString('base64url');
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto.createHash('sha256')
    .update(codeVerifier).digest('base64url');

  // Store state and verifier server-side, keyed by state
  await redis.setex(`par:state:${state}`, 600, JSON.stringify({
    user_id: userId,
    code_verifier: codeVerifier
  }));

  // Push authorization request to authorization server
  const parResponse = await fetch('https://auth.example.com/oauth/par', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      // Client authentication — basic auth or private_key_jwt
      'Authorization': 'Basic ' + Buffer.from(
        `${CLIENT_ID}:${CLIENT_SECRET}`
      ).toString('base64')
    },
    body: new URLSearchParams({
      response_type: 'code',
      client_id: CLIENT_ID,
      redirect_uri: REDIRECT_URI,
      scope: scopes.join(' '),
      state,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256'
    })
  });

  if (!parResponse.ok) {
    const err = await parResponse.json();
    throw new Error(`PAR request failed: ${err.error}`);
  }

  const { request_uri, expires_in } = await parResponse.json();

  // Step 2: redirect browser with only the request_uri reference
  const authUrl = new URL('https://auth.example.com/authorize');
  authUrl.searchParams.set('client_id', CLIENT_ID);
  authUrl.searchParams.set('request_uri', request_uri);

  return { redirectUrl: authUrl.toString(), state };
}

Server-side PAR endpoint implementation

// Authorization server: PAR endpoint (RFC 9126)
app.post('/oauth/par', authenticateClient, async (req, res) => {
  const {
    response_type, redirect_uri, scope, state,
    code_challenge, code_challenge_method
  } = req.body;

  const client = req.oauthClient;

  // Validate redirect URI
  if (!client.redirect_uris.includes(redirect_uri)) {
    return res.status(400).json({ error: 'invalid_redirect_uri' });
  }

  // Validate scopes
  const requestedScopes = scope?.split(' ') ?? [];
  const invalidScopes = requestedScopes.filter(s => !client.allowed_scopes.includes(s));
  if (invalidScopes.length > 0) {
    return res.status(400).json({ error: 'invalid_scope' });
  }

  // Generate the request_uri — a URN with high-entropy random component
  const requestId = crypto.randomBytes(32).toString('base64url');
  const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;

  // Store all authorization request parameters
  await redis.setex(
    `par:${requestId}`,
    90,  // RFC 9126 recommends 5-30 seconds; 90s is generous
    JSON.stringify({
      client_id: client.id,
      response_type,
      redirect_uri,
      scope,
      state,
      code_challenge,
      code_challenge_method,
      created_at: Date.now()
    })
  );

  res.json({
    request_uri: requestUri,
    expires_in: 90
  });
});

// Modified authorization endpoint — resolves request_uri before processing
app.get('/authorize', async (req, res) => {
  const { client_id, request_uri } = req.query;

  if (request_uri) {
    // PAR flow — look up the stored request
    const requestId = request_uri.replace('urn:ietf:params:oauth:request_uri:', '');
    const storedRequest = await redis.getdel(`par:${requestId}`);

    if (!storedRequest) {
      return res.status(400).send('Invalid or expired request_uri');
    }

    const params = JSON.parse(storedRequest);
    if (params.client_id !== client_id) {
      return res.status(400).send('client_id mismatch');
    }

    // Continue with stored parameters
    req.authorizationParams = params;
  }

  // ... render authorization UI
});

PAR in FAPI 2.0

FAPI 2.0 (Financial-grade API), used by open banking implementations worldwide, mandates PAR. The requirement is that authorization servers must require PAR for confidential clients — financial API clients cannot use plain query string authorization requests. Combined with PKCE and private_key_jwt client authentication, PAR ensures that an attacker who intercepts the browser redirect cannot reconstruct or tamper with the authorization request parameters.

PAR also solves a practical length limitation. Some authorization requests — particularly those using JAR (JWT-Secured Authorization Requests) or those with large claims parameters — exceed URL length limits in browsers or intermediary proxies (typically 2,048 characters). PAR removes the URL length constraint entirely since the parameters are sent in a POST body.

For most general-purpose SaaS applications, PAR is an improvement in security posture but not strictly necessary. For financial services, healthcare, and other regulated industries where authorization parameters are considered sensitive, or where you need FAPI compliance, it is a requirement. The implementation overhead is modest — it is primarily a server-side change with a simple corresponding update in client libraries.

← Back to blog Try Bastionary free →