SenseCrypt Docs
Guides

Signed requests and responses

JAR request objects at the PAR endpoint (RFC 9101) and JARM signed authorization responses — the signing rules, the claims that are required, where each is accepted, and how to validate the response JWT.

Two optional hardening steps sit on either side of the authorization request. JAR signs the request your client sends; JARM signs the response SenseCrypt sends back. Both use keys already in play: the client's registered keys for the request, the tenant's signing key for the response.

JAR: signed request objects

A signed request object is a JWS whose claims are the authorization request. It gives you non-repudiation and integrity over the parameters, independent of the browser.

Where it is accepted

Endpointrequest parameter
pushed_authorization_request_endpointAccepted — the signed object is the pushed request
backchannel_authentication_endpoint (CIBA)Accepted — see CIBA
authorization_endpointRefusedrequest_not_supported

Discovery says exactly this: request_parameter_supported is false while request_object_signing_alg_values_supported lists the algorithms /par accepts. The authorization endpoint also refuses a request_uri that is not one SenseCrypt minted, with request_uri_not_supported — the only request_uri it accepts is the PAR URN form (urn:ietf:params:oauth:request_uri:…), which is why request_uri_parameter_supported is true.

Both refusals are delivered as an error redirect when the request carries a redirect_uri registered for your application — on a direct request that is the plain parameter — and otherwise on SenseCrypt's own refusal page. The contents of the object are never read on that path — an issuer that cannot process the object has no basis for trusting a redirect target inside it.

Signing rules

// header
{ "typ": "oauth-authz-req+jwt", "alg": "ES256", "kid": "<your key id>" }
// payload
{
  "iss": "<your client_id>",
  "aud": "https://acme.example.com",
  "nbf": 1893455400,
  "exp": 1893455700,
  "response_type": "code",
  "client_id": "<your client_id>",
  "redirect_uri": "https://yourapp.com/auth/callback",
  "scope": "openid profile offline_access",
  "state": "…",
  "nonce": "…",
  "code_challenge": "…",
  "code_challenge_method": "S256"
}
  • alg must be one the tenant advertises in request_object_signing_alg_values_supported: ES256, PS256 or RS256 on a Standard issuer, ES256/PS256 for an application on the FAPI 2.0/CIBA profile. none and the HMAC algorithms are never accepted.
  • Registered keys verify the signature. Your application must have jwks or jwks_uri on file — the same keys private_key_jwt uses. A mutual TLS application may register them for exactly this purpose. The kid in the header selects the key; a jwks_uri client gets one cache-busting refetch on an unknown kid, so a rotation does not need coordination.
  • typ, if present, must be oauth-authz-req+jwt or the generic JWT (compared case-insensitively). Omitting it is allowed.
  • aud must be — or, as an array, contain — the tenant's issuer identifier.
  • nbf is required: no more than 60 minutes in the past, and no more than 60 seconds in the future.
  • exp is required, must be strictly in the future (no skew allowance), and no more than 60 minutes after nbf.
  • No nesting. A request or request_uri claim inside the object is refused.
  • iss and client_id, when present, must equal the authenticated client.
  • The CIBA profile requires the full envelope — see CIBA.

What happens to your form parameters

Per RFC 9101 §6.3 the object's claims replace the form parameters wholesale. Only the client-authentication fields are read from the form (plus a client_id, which must match the authenticated client). Every authorization parameter — scope, state, nonce, redirect_uri, code_challenge, max_age, claims, prompt, id_token_hint, response_mode, dpop_jkt, resource/audience — must be inside the object. A parameter you leave in the form only is simply not part of the request.

The pushed object then goes through the same gates as a form push: the response type must be one your application registered, redirect_uri must be on its allow-list, PKCE is enforced by your application's require_pkce, and the audience gate applies.

Requiring it

Setting require_signed_request_object on an application (RFC 9101 §10.5) makes signing mandatory:

  • Registration needs keys on file, or it is refused 422 with code: "jar.requires_keys".
  • An unsigned push is refused invalid_request.
  • A direct /authorize is refused invalid_request as well. The authorization endpoint accepts no signed object at all, so there "must be signed" can only mean "must be pushed" — the same rule a FAPI application is held to.
  • At the backchannel authentication endpoint it means what it says: a CIBA request that carries its parameters in the clear is refused invalid_request. FAPI-CIBA applications are held to the same rule, whether or not you set this flag.

Refusals

Every verification failure at PAR is 400 invalid_request_object with one fixed description — the specific reason is logged, not returned, so a client cannot use the endpoint to probe key state. At the CIBA endpoint the same failures are 400 invalid_request.

JARM: signed authorization responses

With JARM, the authorization response parameters travel as the claims of a single signed JWT in one response parameter, instead of as separate query or fragment parameters.

Ask for it with response_mode (pushed with the rest of your request):

response_modeResponse
query.jwt?response=<JWT> on the redirect
fragment.jwt#response=<JWT>
form_post.jwtAn auto-submitting form POSTing response=<JWT>
jwtResolves to query.jwt for response_type=code, and to fragment.jwt for a type that returns tokens from the authorization endpoint

The advertised set is response_modes_supported; a FAPI 2.0/CIBA issuer offers query, jwt and query.jwt only.

The response JWT

{
  "iss": "https://acme.example.com",
  "aud": "your-client-id",
  "iat": 1893455400,
  "exp": 1893455520,
  "code": "…",
  "state": "the-state-you-sent"
}
  • Signed with the tenant key for your application — the same signer as its id_tokens, so the algorithm is your id_token_signed_response_alg and the advertised set is authorization_signing_alg_values_supported. Select the key by kid from the tenant's jwks_uri, exactly as for an id_token.
  • iss is the tenant's issuer, aud is your client_id, and exp is about two minutes after iat — long enough for the redirect to land, short enough that a captured response is soon useless.
  • Every response parameter is a claim: code and state on success, or error / error_description / state on a refusal, or the front-channel artefacts for an implicit or hybrid type.
  • Errors are JARM too. A refusal on a request that asked for a .jwt mode comes back as a signed response, not as plain error= parameters — so parse the JWT before you look for an error.
  • The RFC 9207 iss is inside the JWT, not a second parameter beside it. On the non-JARM modes it is a plain response parameter.
  • No encryption. SenseCrypt signs the response; it does not offer an encrypted (JWE) authorization response, and advertises no authorization_encryption_* metadata.

Validating it

import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));

const raw = new URL(request.url).searchParams.get("response");
const { payload } = await jwtVerify(raw, jwks, {
  issuer,                 // the `issuer` from discovery
  audience: clientId,     // your client_id
});

if (payload.state !== expectedState) throw new Error("state mismatch");
if (payload.error) throw new Error(`authorization refused: ${payload.error}`);

const code = payload.code; // then exchange it at the token endpoint as usual

jwtVerify checks the signature, iss, aud and exp for you. Check state yourself, then branch on error before reading code — and keep the ordinary rules for the exchange that follows: the code is single-use and bound to your client_id and redirect_uri.

A response mode that returns token material can never ride the query: asking for query or query.jwt together with an implicit or hybrid response_type is refused invalid_request at the push, before any request_uri exists — so your client is never handed a response in a mode it cannot parse.

On this page