A pushed authorization request (PAR, [RFC 9126](https://datatracker.ietf.org/doc/html/rfc9126))
is the authorization request in two steps. Your server POSTs the parameters it
would have put in the redirect to us first, authenticated as your client. We
hand back a short `request_uri`. You then redirect the person to
`/oauth/oidc/authorize` with only your `client_id` and that `request_uri`.

Everything after that - verification, consent, the code, the token exchange,
userinfo - is exactly the same as the direct flow.

{% callout type="info" %}
If you have a `client_secret`, start your logins this way. The direct
query-string request still works and is not going away, but the pushed one is
the safer default and the one we recommend.
{% /callout %}

## Why push

**The request cannot be changed on the way through.** In the direct flow the
browser carries `claims`, `email`, `phone` and `ruleset` in the address bar.
Anyone or anything between your redirect and our server can see them and, in
principle, edit them: a proxy log, a shared screenshot, a browser extension. With
a pushed request we only act on a set of parameters your server sent us with
your secret attached. The browser never sees them, and the full request does
not leak in a `Referer` header either.

**The URL stays short, whatever you ask for.** Our `claims` parameter is JSON
and grows with every claim you request. Twelve claims make a direct authorize
URL of around 850 bytes, and it keeps growing. Some email clients, SMS
gateways, chat apps and proxies truncate or rewrite long URLs. A PAR authorize
URL is under 200 bytes and does not change shape when your claims list does.

## When to use it

*   **Use it if your client is confidential.** That is any client with a
    `client_secret`, which means a server-side application.

*   **Do not use it from a public client.** A single-page app or a mobile app
    has no secret, and the push has to be authenticated to mean anything. Public
    clients keep using the direct request, protected by PKCE as they are today.

*   **Push when the person clicks, not when the page renders.** A `request_uri`
    is good for 10 minutes and can be used once. Make the push in the handler
    behind your "Continue with TrueVault" button, then redirect.

PAR is optional here: `require_pushed_authorization_requests` is `false` in the
discovery document.

## Step 1: push the parameters

POST the same parameters you would have put in the authorize redirect to the
`pushed_authorization_request_endpoint` from
[the discovery document](/oauth-oidc-reference/endpoints/discovery-metadata).
PKCE is still mandatory, so `code_challenge` and `code_challenge_method=S256`
go in the push.

Authenticate the way your client authenticates at the token endpoint. Clients
are set to one method, not both: send either the `Authorization: Basic` header
(`client_secret_basic`) or `client_id` and `client_secret` in the form body
(`client_secret_post`). A push that carries both is turned away.

**With `client_secret_basic`:**

```bash
curl -X POST https://sandbox.truevault.com.au/oauth/oidc/par \
  -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
  -d response_type=code \
  -d "redirect_uri=https://your-app.com/callback" \
  -d "scope=openid" \
  -d "claims={\"true\":[\"trueidentity.valid\"]}" \
  -d state=RANDOM_STATE \
  -d nonce=RANDOM_NONCE \
  -d code_challenge=YOUR_CODE_CHALLENGE \
  -d code_challenge_method=S256
```

**With `client_secret_post`:**

```bash
curl -X POST https://sandbox.truevault.com.au/oauth/oidc/par \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d response_type=code \
  -d "redirect_uri=https://your-app.com/callback" \
  -d "scope=openid" \
  -d "claims={\"true\":[\"trueidentity.valid\"]}" \
  -d state=RANDOM_STATE \
  -d nonce=RANDOM_NONCE \
  -d code_challenge=YOUR_CODE_CHALLENGE \
  -d code_challenge_method=S256
```

The push accepts every parameter the authorize endpoint does: `response_type`,
`redirect_uri`, `scope`, `claims`, `state`, `nonce`, `code_challenge`,
`code_challenge_method`, and the optional `ruleset`, `aobo`, `phone`,
`phone_country_code` and `email`.

**Response (HTTP 201):**

```json
{
  "request_uri": "urn:ietf:params:oauth:request_uri:cPkc0bdMi3tYpeEYAPR57ZizsansgCuf",
  "expires_in": 600
}
```

`expires_in` is in seconds. Treat the `request_uri` as opaque - it is only
meaningful to us, and only once.

## Step 2: redirect the browser

Send the person to the authorize endpoint with `client_id` and `request_uri`
and nothing else:

```http
GET /oauth/oidc/authorize
  ?client_id=YOUR_CLIENT_ID
  &request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3AcPkc0bdMi3tYpeEYAPR57ZizsansgCuf
Host: sandbox.truevault.com.au
```

Any other parameter in that query string is ignored. The pushed set is the only
one that counts, so there is no way to add a claim or change the `redirect_uri`
after the push.

The person then comes back to your `redirect_uri` with a `code` and your
`state`, and you carry on as in
[the quick start](/oauth-oidc-reference/quick-start-guide): check the `state`,
exchange the code with the original `code_verifier`, validate the `id_token`,
call userinfo.

## In code

**PHP:**

```php
// Step 1: push, authenticated with client_secret_basic
$discovery = json_decode(file_get_contents(
    'https://sandbox.truevault.com.au/.well-known/openid-configuration'
), true);

$push = curl_init($discovery['pushed_authorization_request_endpoint']);
curl_setopt_array($push, [
    CURLOPT_POST => true,
    CURLOPT_USERPWD => $client_id . ':' . $client_secret,
    CURLOPT_POSTFIELDS => http_build_query([
        'response_type' => 'code',
        'redirect_uri' => 'https://your-app.com/callback',
        'scope' => 'openid',
        'claims' => json_encode(['true' => ['trueidentity.valid']]),
        'state' => $state,
        'nonce' => $nonce,
        'code_challenge' => $code_challenge,
        'code_challenge_method' => 'S256',
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$par = json_decode(curl_exec($push), true);

// Step 2: redirect with only client_id and request_uri
header('Location: ' . $discovery['authorization_endpoint'] . '?' . http_build_query([
    'client_id' => $client_id,
    'request_uri' => $par['request_uri'],
]));
```

**Node:**

```js
// Step 1: push, authenticated with client_secret_basic
const discovery = await fetch(
  'https://sandbox.truevault.com.au/.well-known/openid-configuration'
).then((r) => r.json());

const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const par = await fetch(discovery.pushed_authorization_request_endpoint, {
  method: 'POST',
  headers: {
    Authorization: `Basic ${basic}`,
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: new URLSearchParams({
    response_type: 'code',
    redirect_uri: 'https://your-app.com/callback',
    scope: 'openid',
    claims: JSON.stringify({ true: ['trueidentity.valid'] }),
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  }),
}).then((r) => r.json());

// Step 2: redirect with only client_id and request_uri
const authorizeUrl = new URL(discovery.authorization_endpoint);
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('request_uri', par.request_uri);
res.redirect(authorizeUrl.toString());
```

Most OIDC libraries already speak PAR. Look for a `pushedAuthorizationRequest`
or `par` option before writing the call yourself.

## Errors

The push returns standard OAuth 2.0 error JSON:

```json
{
  "error": "invalid_client",
  "error_description": "Invalid client credentials"
}
```

| What happened | `error` | HTTP status |
|---|---|---|
| Anything other than POST | `invalid_request` | 405 |
| No `client_id` in the header or body | `invalid_request` | 400 |
| Unknown client, or wrong secret | `invalid_client` | 401 |
| Credentials in both the header and the body | `invalid_client` | 401 |
| A public client pushed | `unauthorized_client` | 401 |
| `response_type` or `scope` missing | `invalid_request` | 400 |

At the authorize endpoint a bad `request_uri` is also `invalid_request`
(HTTP 400), with an `error_description` that says which: the `request_uri` has
expired or is unknown, was already used, or does not match the `client_id` you
sent with it.
The full list is in [Error handling](/oauth-oidc-reference/error-handling).

## Discovery

The discovery document tells you where to push and that pushing is optional:

```json
{
  "pushed_authorization_request_endpoint": "https://sandbox.truevault.com.au/oauth/oidc/par",
  "require_pushed_authorization_requests": false,
  "request_uri_parameter_supported": false
}
```

`request_uri_parameter_supported` is `false` because that flag is about
OpenID Connect request objects by reference (JAR), which we do not support. A
PAR `request_uri` is different and is always accepted at the authorize
endpoint.

## Try it

On a sandbox account, the built-in test relying party at `/test-oidc` has a
"Start Login (PAR)" button. The "(inspect)" link beside it shows the exact form
that was pushed, the 201 response, the short authorize URL and how many bytes it
saved compared with the direct request, before it sends you on. Reloading the
authorize URL afterwards shows the "already used" error.
