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
| Endpoint | request parameter |
|---|---|
pushed_authorization_request_endpoint | Accepted — the signed object is the pushed request |
backchannel_authentication_endpoint (CIBA) | Accepted — see CIBA |
authorization_endpoint | Refused — request_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"
}algmust be one the tenant advertises inrequest_object_signing_alg_values_supported:ES256,PS256orRS256on a Standard issuer,ES256/PS256for an application on the FAPI 2.0/CIBA profile.noneand the HMAC algorithms are never accepted.- Registered keys verify the signature. Your application must have
jwksorjwks_urion file — the same keysprivate_key_jwtuses. A mutual TLS application may register them for exactly this purpose. Thekidin the header selects the key; ajwks_uriclient gets one cache-busting refetch on an unknownkid, so a rotation does not need coordination. typ, if present, must beoauth-authz-req+jwtor the genericJWT(compared case-insensitively). Omitting it is allowed.audmust be — or, as an array, contain — the tenant's issuer identifier.nbfis required: no more than 60 minutes in the past, and no more than 60 seconds in the future.expis required, must be strictly in the future (no skew allowance), and no more than 60 minutes afternbf.- No nesting. A
requestorrequest_uriclaim inside the object is refused. issandclient_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
422withcode: "jar.requires_keys". - An unsigned push is refused
invalid_request. - A direct
/authorizeis refusedinvalid_requestas 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_mode | Response |
|---|---|
query.jwt | ?response=<JWT> on the redirect |
fragment.jwt | #response=<JWT> |
form_post.jwt | An auto-submitting form POSTing response=<JWT> |
jwt | Resolves 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_algand the advertised set isauthorization_signing_alg_values_supported. Select the key bykidfrom the tenant'sjwks_uri, exactly as for an id_token. issis the tenant's issuer,audis yourclient_id, andexpis about two minutes afteriat— long enough for the redirect to land, short enough that a capturedresponseis soon useless.- Every response parameter is a claim:
codeandstateon success, orerror/error_description/stateon 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
.jwtmode comes back as a signedresponse, not as plainerror=parameters — so parse the JWT before you look for an error. - The RFC 9207
issis 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 usualjwtVerify 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.
Related
- Security profiles — where signed requests become mandatory.
- Sender-constrained tokens — DPoP and mutual TLS.
- OIDC & OAuth 2.0 — PAR, discovery and the endpoint list.
- CIBA — signed backchannel authentication requests.
- Error codes —
request_not_supported,invalid_request_objectand the rest.
Sender-constrained tokens
Bind access tokens to a key your client holds — DPoP (RFC 9449) proofs and mutual TLS (RFC 8705) certificates, the cnf claim, the DPoP nonce retry, refresh-token binding, and what a resource server must check.
Account lifecycle and deletion
How a SenseCrypt account is closed and reopened — owner-only self-delete with a 30-day grace, the 423 block on the admin console during that window, what reactivation restores, and the crypto-shred and data erasure that happen on final deletion.