ASVS 5.0 V10.4.15 (Level 3) says that for a server-side client, the authorization server must make sure the authorization_details value came from the client backend and was not changed by the user. Requiring PAR or JAR is the usual way.
authorization_details is the Rich Authorization Requests (RFC 9396) parameter. It carries structured permission data, for example a payment of a specific amount to a specific account.
Why it matters
Scopes are coarse strings. Rich requests can hold amounts, recipients and resources. If that data rides through the browser as a URL parameter, a user or malware can edit the amount or account before the consent screen is built, and the consent then shows the altered values as if the app had asked for them.
How to implement it
Require that the parameter arrives only through a channel the user cannot touch.
With PAR, the backend posts it directly:
curl -s https://auth.example.com/par \
-u "pay-backend:$CLIENT_SECRET" \
--data-urlencode 'authorization_details=[{"type":"payment_initiation","instructedAmount":{"currency":"EUR","amount":"25.00"},"creditorAccount":{"iban":"DE02120300000000202051"}}]' \
-d response_type=code -d client_id=pay-backend \
-d redirect_uri=https://pay.example.com/cb \
-d code_challenge="$CHALLENGE" -d code_challenge_method=S256
With JAR, the backend sends the parameters inside a signed request object that the server verifies against the client's registered key.
On the server, reject any direct authorization request that contains authorization_details in the query string for these clients.
Validating the structure on the server
Once the data arrives safely, treat it as untrusted input to a policy engine. Validate each detail against a schema registered for its type, reject unknown types, and reject unknown fields rather than ignoring them. Limit sizes so a client cannot push huge documents.
ALLOWED_TYPES = {"payment_initiation": PaymentSchema}
def parse_authorization_details(raw):
items = json.loads(raw)
if not isinstance(items, list) or len(items) > 5:
raise InvalidAuthorizationDetails()
parsed = []
for item in items:
schema = ALLOWED_TYPES.get(item.get("type"))
if schema is None:
raise InvalidAuthorizationDetails()
parsed.append(schema.load(item)) # raises on unknown or invalid fields
return parsed
Binding the approval to the data
When the user approves, bind the consent to the exact details that were shown, for example by storing a hash of the canonical form with the pending request. At token issuance, compare the hash again. This stops a change between consent and issuance, which is a gap people often miss.
Server side client or not
The requirement targets clients that run on a server, because they can authenticate to PAR and sign requests. For clients on the user's device, the user could change anything locally, so other controls matter more there. Make the distinction explicit in client registration.
How to test it
Send a front channel authorization request with authorization_details in the URL for a server-side client. Expect rejection. Send it through PAR and confirm the consent screen shows exactly what the backend posted. Tamper with a signed request object and expect signature failure.
What a reviewer will ask
Expect these questions. Can the parameter ever arrive in a front channel request. Is the PAR or JAR requirement enforced by the server, or only recommended to clients. Does the consent screen render from the stored data. Have tests shown that edits to the URL are ignored. Having those answers ready makes an assessment quick.
Common mistakes
- Accepting the parameter on both channels.
- Rendering the consent from browser supplied data.
- Not authenticating the PAR call.
Quick check
Why are scopes not enough here?
Rich requests carry specifics such as amounts and recipients, which makes tampering far more damaging than changing a scope word.