Skip to content

JARM — JWT Authorization Response Mode ​

By default the OP returns the authorization response (code, state, iss, …) as plain URL query parameters; form_post is the alternative non-JARM delivery. Anyone who can rewrite the redirect — a malicious browser extension, a compromised intermediary, a mix-up between two OPs — can substitute fields before the RP sees them. JARM (the OpenID Foundation FAPI WG specification "JWT Secured Authorization Response Mode for OAuth 2.0", referenced informationally by RFC 9101 §10.2) wraps that response into a single signed JWT delivered through one response parameter, so the response itself becomes tamper-evident and cryptographically attributable to the OP.

Loose parameters, or one signed document
A plain authorization response returns the code, the state and the issuer as separate query parameters, each of which the Relying Party has to check on its own. JARM wraps the same values in a signed JWT returned as a single response parameter.plain responsejarm responseOPreturns the fields as they arethree separate parameters?code=… & state=… & iss=…nothing binds them togetherRPchecks each field on its ownOPsigns the whole responseone signed JWTiss · aud · exp · code, or error?response=<JWT>RPverifies one signature, then reads
On the left, an attacker who can rewrite the query string can swap one parameter and leave the rest intact. On the right there is nothing to swap — the values are inside a document that only the OP's key can produce.

JARM is the signed-authorization-response method defined by FAPI 2.0 Message Signing. The same specification treats signed authorization requests and signed introspection responses as separate methods; it does not define a signature wrapper for every resource-server API request and response. Each method has its own conformance option, so a deployment can choose the messages it needs.

Specs referenced on this page

Why JARM exists ​

The classic redirect carries code and state as query parameters. Two attack shapes target that wire format:

  • Mix-up. The user is logged into two OPs simultaneously. An attacker who controls one of them can swap their own code for the legitimate OP's code in the redirect, tricking the RP into exchanging the attacker's code at the legitimate OP's /token and binding the attacker's identity to the user's session.
  • Mid-channel rewrite. Anything that sits between the user agent and the RP — a browser extension, a corporate proxy, a compromised CDN edge — can rewrite the response before the RP receives it. PKCE binds the code to the RP-chosen verifier so the rewriter cannot redeem the substituted code itself, but it does not prove which OP issued the code the RP ends up exchanging.

OAuth 2.0 JARM §1 (paraphrased) the authorization response is cryptographically protected so that neither the issuer of the response nor the values it carries can be tampered with after the OP signs it.

RFC 9207 (the iss parameter on the authorization response) is the lightweight defence: the OP appends its issuer URL to the redirect, the RP rejects any redirect whose iss does not match the OP it sent the user to. That stops the mix-up class without a JWT round-trip, and the FAPI 2.0 Security Profile mandates it. JARM is the heavyweight option, used when the response itself must carry a portable signature — for audit trails, for on-the-wire forwarding through brokers, or when the signed-authorization-response method is selected from FAPI 2.0 Message Signing.

Wire format ​

The client opts in by setting response_mode on the authorize (or PAR) request to one of the four JARM values (see internal/jarm/mode.go):

response_modeDeliveryUsed for
query.jwt?response=<JWT> on the redirect URLCode flow; the bare jwt alias resolves here
fragment.jwt#response=<JWT> on the redirect URLCode or error envelope delivered in the URL fragment
form_post.jwthidden response field in an auto-submitted HTML formBrowsers that need to avoid the URL bar
jwtbare alias — always resolved to query.jwtDefault-aware clients

The OP returns one parameter — response — whose value is the JWT. Nothing else lives on the redirect. The JWT carries the original response fields as claims (see internal/jarm/encode.go):

ClaimMeaning
issOP issuer URL (matches discovery's issuer)
audthe requesting client's client_id
expshort-lived expiry; the library defaults to 60 seconds (jarm.DefaultExpiry)
iat, nbfissued-at and not-before, set equal so a strict consumer can apply a uniform nbf-or-fail rule
codesuccess path — the authorization code
statesuccess or error path, when the request supplied it
error, error_description, error_urierror path — same triple as a non-JARM error response

code and error are mutually exclusive; the OP populates one or the other. Validation lives in internal/jarm/encode.go's validatePayload and is enforced by every Sign call.

Signing ​

The OP signs JARM with the same key it uses for ID Tokens and JWT access tokens. The supported signing algorithm is a closed list:

Source: internal/jarm/encode.go — deriveJWSAlgorithm rejects every key shape other than ECDSA P-256 with jose.ErrUnsupportedKeyShape. The signature algorithm is therefore always ES256.

There is no authorization_signed_response_alg per-client override. Discovery advertises a single value:

json
"authorization_signing_alg_values_supported": ["ES256"]

alg=none, RS256, and the HS* family are structurally absent from the type — see Design judgments #11 for the closed-enum rationale that applies uniformly to every JOSE surface in the library.

This ES256-only behavior is a library choice. The Final FAPI 2.0 Security Profile and Message Signing specification allow the FAPI JWT algorithm set (PS256, ES256, and EdDSA) where their respective signed objects use JWT signatures.

Why a single alg and not a negotiable list?

JOSE alg negotiation is the historical source of alg=none and the HMAC-with-public-key ("alg confusion") class of bugs. The library refuses to negotiate: the wire-side allow-list and the type-level enum agree, and any input naming an alg outside the list is rejected before the bytes reach a verifier. Its ES256-only JARM signer is an implementation choice, not an ES256-only requirement of the FAPI specifications.

Encryption ​

A client that registered authorization_encrypted_response_alg and authorization_encrypted_response_enc receives a JARM response wrapped in a JWE around the signed JWT (JWS-then-JWE). The wrapping happens in internal/authorizeendpoint/jarm_encrypt.go's maybeEncryptJARM:

  • The OP looks up the client's encryption JWKs through internal/clientencjwks/ (statically registered or fetched from the client's jwks_uri).
  • The signed JARM JWT is encrypted to the resolved recipient using the registered alg / enc pair.
  • If the encryption fails, the OP refuses to fall back to a plaintext ?code=... redirect — it returns server_error instead. Sending the code in the clear after the client demanded an encrypted envelope would be a worse outcome than failing closed.

The alg / enc allow-list is the same closed list the library uses for ID Token / userinfo / introspection encryption (op.SupportedEncryptionAlgs and op.SupportedEncryptionEncs): RSA-OAEP-256, ECDH-ES, ECDH-ES+A128KW / ECDH-ES+A256KW for key management, and A128GCM / A256GCM for content encryption. RSA1_5, dir, A*KW / A*GCMKW, and the AES-CBC-HS content encryption variants are intentionally excluded. See JWE encryption for the full alg matrix and the rationale.

The OP advertises authorization_encryption_alg_values_supported and _enc_values_supported in discovery only when both the JARM feature and op.WithEncryptionKeyset are wired (see internal/discovery/document.go).

How clients verify ​

The client treats ?response=<JWT> (or its fragment / form_post equivalent) as the entire authorization response. Verification follows the OAuth 2.0 JARM specification §4.4:

  1. Fetch the OP's JWKS from the jwks_uri advertised in discovery.
  2. Verify the JWT signature against the matching kid.
  3. Check iss equals the configured OP issuer (string-equal, no normalisation).
  4. Check aud equals the client's own client_id.
  5. Check exp is in the future (and, for strict consumers, nbf is in the past).
  6. If the JWT is encrypted, decrypt with the client's private encryption key first, then verify the inner signature against the OP's signing JWKS.

Once those checks pass, the client extracts code, state, and the optional error triple from the JWT claims and proceeds exactly as if the values had arrived as plain query parameters. The redirect-URI exact-match check, the state round-trip check, and the PKCE code-verifier computation all run on the values pulled out of the JWT.

When JARM is required ​

Profile / contextJARM status
FAPI 2.0 Message Signingrequired when the signed-authorization-response method is selected (feature.JARM is auto-enabled by op.WithProfile(profile.FAPI2MessageSigning)) — see FAPI 2.0
FAPI 2.0 Security Profileoptional (RFC 9207 iss parameter is sufficient)
FAPI-CIBAnot applicable — CIBA has no front-channel authorization response
Plain OIDC / OAuthpure choice — most deployments rely on RFC 9207 iss and skip JARM

JARM is never the only mitigation against a particular CVE class. The role it plays is cryptographic auditability of the authorization response, which matters most when the response is forwarded out-of-band (broker architectures or logs) or when a deployment selects signed responses as part of its message-integrity contract.

Wiring this library ​

JARM is gated by feature.JARM (see op/feature/feature.go). Two ways to enable it:

Profile-driven. op.WithProfile(profile.FAPI2MessageSigning) adds JARM to the required feature set alongside PAR and JAR (op/profile/constraints.go — RequiredFeatures). The build-time validator refuses to construct an OP under this profile without feature.JARM.

go
import (
  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/profile"
)

op.New(
  /* required options */
  op.WithProfile(profile.FAPI2MessageSigning),
)

Manual feature flag. Add feature.JARM directly when you want JARM outside FAPI 2.0 Message Signing — for example, on an OIDC deployment that wants signed responses for audit reasons.

go
import (
  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/feature"
)

op.New(
  /* required options */
  op.WithFeature(feature.JARM),
)

The signer is constructed once at op.New (see op/op_builders.go — buildJARMSigner) using the active OP signing key. No separate keyset, no per-request key lookup.

Per-client metadata. When feature.JARM is on, the client metadata accepts (see op/registration.go):

FieldEffect
authorization_encrypted_response_algRequest a JWE-wrapped JARM response with the named JWE alg. Must be on op.SupportedEncryptionAlgs.
authorization_encrypted_response_encPair with authorization_encrypted_response_alg. Must be on op.SupportedEncryptionEncs.

Both fields can be supplied at static seed time (the op.ClientSeed / store.Client shape) or through Dynamic Client Registration (POST /register). The library does not implement an authorization_signed_response_alg per-client override; the OP-wide ES256 setting applies to every JARM-using client.

JARM and DPoP / mTLS ​

JARM signs the authorization response. DPoP and mTLS bind issued tokens to a key the legitimate client holds. The two layers are orthogonal — JARM protects the handshake step that delivers the code, sender constraint protects the tokens issued in exchange for that code — and the FAPI 2.0 Security Profile requires one sender-constraint method. Message Signing inherits that requirement.

The library's op.WithProfile(profile.FAPI2MessageSigning) combines PAR, JAR, and JARM and imposes the DPoP-or-mTLS RequiredAnyOf constraint; DPoP is selected by default unless mTLS is explicitly enabled. When DPoP is active, the library also requires a configured DPoP nonce source. In the Final Message Signing specification, those signing methods remain independently selectable and a DPoP nonce is not required.

  • FAPI 2.0 — where JARM fits in the FAPI 2.0 Security Profile / Message Signing split.
  • Sender constraint — selection guide — DPoP vs mTLS for the token-binding layer that runs alongside JARM.
  • FAPI 2.0 Security Profile — full wiring for the Security Profile, the starting point before adding JARM.
  • JWE encryption — encrypted JARM, encrypted ID Token, encrypted userinfo, encrypted introspection (one keyset, four targets).
  • Design judgments — closed alg enum (#11) and other resolved spec tensions that JARM inherits.