Response validation and failures
A JWT has arrived at the printer's callback. It contains a code, and the printer would like to exchange it. JARM sets a firm rule about that: the client must not process the code, or any other parameter of the response, until every check on the JWT has passed.
Checks before the code is used
The printer works through the response in this order:
- Decrypt it, if it was encrypted, with the printer's own private key.
- Check the issuer. Read
issand compare it with the issuer this browser session's pending attempt was sent to,https://auth.photos.example. Stop if it differs. - Check the audience.
audmust be the client ID the printer used in the request,photo-printer. - Check the expiry.
expmust not have passed, allowing at most a small tolerance for clock differences. - Verify the signature with a key from the photo service's key set, using an algorithm the printer expects.
noneis never accepted. - Only now use the contents. Match
stateto the pending attempt in this session, then exchange the code with the PKCE verifier and client authentication, as in any code flow.
The order of steps 2 and 5 may look backwards. Why read the issuer before the signature is verified? Because the issuer decides which keys to verify with, and the printer must not let an unverified JWT choose them. The printer looks up the issuer it expected in its own configuration and uses the key set configured for that issuer. If it fetched keys from wherever an incoming JWT said its issuer lived, an attacker could make the printer fetch from an address of the attacker's choosing, a slow or enormous one, or someone else's server it should not be calling.
In code, the order looks like this:
const pending = session.pendingAttempt(); // saved when the request was sent
const jwt = callbackParams.get('response');
const claims = decodeWithoutTrusting(jwt); // readable, not yet trusted
if (claims.iss !== pending.issuer) return reject('unexpected_issuer');
if (claims.aud !== pending.clientId) return reject('wrong_audience');
if (claims.exp <= nowInSeconds() - CLOCK_TOLERANCE) return reject('expired_response');
const keys = await keySetFor(pending.issuer); // from configuration, never from the JWT
if (!verifySignature(jwt, keys, ['ES256'])) return reject('bad_signature');
// Every check passed. Now state and code may be used.
if (claims.state !== pending.state) return reject('state_mismatch');
await exchangeCode(claims.code, pending.codeVerifier);
The helper names are illustrative. What matters is that no branch reaches exchangeCode without passing every check above it.
Errors inside the JWT
All values in these examples are fictional. When you decline the connection, the error arrives in the same form as a success. Its decoded claims read:
{
"iss": "https://auth.photos.example",
"aud": "photo-printer",
"exp": 1790845500,
"error": "access_denied",
"state": "demo-attempt-7"
}
The printer validates it exactly like a success, through step 5, before believing it. A verified error is reliable information: the photo service really did refuse, for this attempt. An error that fails validation is not evidence that you or the photo service refused anything, and the printer should not record your connection as declined because of it.
A plain error=access_denied in the query string deserves the same caution. The printer asked for a signed response, so an unsigned one at its callback could have been written by anyone. The safest reading is that the attempt failed for an unknown reason, with an honest message and a fresh attempt on offer. Some failures never reach the callback at all. If the return address itself is invalid, the photo service shows its own error page and does not redirect, signed response or not.
The iss parameter and the iss claim
Without JARM, the printer learns which server answered from the plain iss response parameter. JARM puts the same value inside the signed JWT. Validated the same way, the claim gives the same protection against authorization server mix-up as the parameter, and it is covered by the signature as well. A separate iss parameter outside the JWT is therefore unnecessary, and the FAPI 2.0 Message Signing profile advises servers to send iss only inside the JWT.
A server might still send both. Then the response contains two issuer identifiers, and they must match exactly. If they differ, the printer rejects the response rather than choosing whichever one suits it.
When a check fails
Most failures have mundane causes, and a few deserve a closer look:
| Failure | Likely cause | What the printer does |
|---|---|---|
Unknown kid | The photo service has started signing with its next key, photos-2027-01, and the printer's cached key set is old. | Fetch the key set again from its configured address, at most once in a short period, and verify again. Reject if the key is still unknown. |
Expired exp | The response was delayed or replayed, or the printer's clock is wrong. | Reject and offer a fresh attempt. Check the printer's own clock if this happens often. |
Wrong aud | A response issued to another client reached this callback, for example through a misconfigured test deployment or a deliberate replay. | Reject. |
Unexpected iss | Another server answered, or someone is attempting a mix-up. | Reject, and never send the code anywhere. |
Bad signature or unexpected alg | The JWT was altered or forged. | Reject. |
Every row ends the same way. The printer makes no token request, keeps the code out of its logs, and tells you it could not connect your photo account, with an option to try again. Its own log records the stage, the failed check, and the attempt's correlation ID, never the JWT itself, which still contains a code.
The issuer checks in this lesson follow the same comparison rules as the plain iss parameter. The next group, Authorization response issuer identification, sets out those rules and what the issuer check can and cannot protect.