Checking the response issuer
A callback arrives at the printer carrying iss=https%3A%2F%2Fauth.photos.example. The parameter only helps if the printer knows what it expected to see, compares the two correctly, and knows what to do when the parameter is missing. Each of those steps has a way to go wrong.
Remembering who was asked
All domains and identifiers in these examples are fictional. The printer keeps a configuration record for each authorization server it works with, built from that server's metadata:
Photo service
Issuer: https://auth.photos.example
Token endpoint: https://auth.photos.example/token
Client ID: photo-printer
Sends iss: yes, advertised in its metadata
Pixel Vault
Issuer: https://auth.pixelvault.example
Token endpoint: https://auth.pixelvault.example/oauth/token
Client ID: 8f3c21e9-6b4d-4a1f-9e2c-5d07b8a4c613
Sends iss: no, not advertised
When you choose a service and select Connect, the printer records that service's issuer identifier in the pending transaction, as Correlating requests and responses showed. It records the issuer rather than the address of the authorization endpoint it sent your browser to, because the issuer stands for the whole configuration, including the token endpoint the code must eventually go to.
The records must also stay distinct. Two configured servers must never share an issuer identifier. If an administrator could add a server by hand and type the photo service's issuer into a record that points at someone else's token endpoint, the issuer check would pass for the wrong server. A client that allows manual configuration must refuse a duplicate.
Comparing the value
The printer reads iss from the callback, decodes it once from its form encoding, and compares the result with the issuer in the pending transaction using simple string comparison: the two strings must be identical, character for character. No normalization is applied, so values that a browser might treat as the same address do not match:
Decoded iss | Compared with https://auth.photos.example |
|---|---|
https://auth.photos.example | Match. |
https://auth.photos.example/ | No match. The trailing slash makes a different string. |
https://AUTH.photos.example | No match, although host names are not case-sensitive. |
https://auth.photos.example:443 | No match, although 443 is the default port for HTTPS. |
https://auth.pixelvault.example | No match. A different server answered. |
Strictness costs the photo service nothing, because it is required to send exactly its own identifier every time. Leniency, on the other hand, would cost the printer something. Libraries normalize URLs differently, and every rule that treats two strings as equal is a rule an attacker can look for a way to exploit. The same goes for decoding: the value is decoded once, and a printer that decoded it a second time could turn a carefully encoded string into one that matches.
A small function captures the check:
function checkIssuer(callback: URLSearchParams, pending: PendingAttempt): void {
const server = pending.server; // configuration recorded when the attempt began
const received = callback.get('iss'); // URLSearchParams has already decoded it once
if (received === null) {
if (server.sendsIss) throw new CallbackRejected('missing_iss');
return; // allowed only with another mix-up defense
}
if (!server.sendsIss) throw new CallbackRejected('unexpected_iss');
if (received !== server.issuer) throw new CallbackRejected('issuer_mismatch');
}
The received value is only ever compared. It is never used to look up a configuration, choose a token endpoint, or fetch metadata. If the printer let the response tell it which server had answered, an attacker would simply tell it.
When iss is missing or unexpected
The function handles four situations:
| The response | The expected server | The printer |
|---|---|---|
Carries the expected iss | Sends iss | Continues with its other checks, such as state. |
Carries a different iss | Any | Rejects the response and does not use the code. |
Carries no iss | Sends iss | Rejects the response. The parameter's absence is itself a warning. |
Carries no iss | Does not send iss | Accepts it only if local policy allows this server and another mix-up defense covers it. |
The awkward case is a response that carries iss from a server that never advertised support. The specification says clients should discard such responses, while acknowledging that some legitimate servers send the parameter without advertising it. The printer's function rejects them. A client that knows a particular server behaves this way can record that in its configuration and treat the server as one that sends iss.
Error responses get the same treatment. A callback carrying error=access_denied with an unexpected issuer, or with no issuer from a server that always sends one, is not evidence that the expected service refused anything. The printer should not tell you that Pixel Vault declined the connection on the strength of a message Pixel Vault may not have sent. It reports that the connection could not be completed and offers to start again.
OpenID Connect adds one more rule. When an ID token, the sign-in token OpenID Connect defines, comes back from the authorization endpoint in the same response, the iss parameter must be identical to the iss claim inside it.
A rejected issuer is worth recording carefully. The printer logs the stage, the reason, such as issuer_mismatch, the expected issuer, and the attempt's correlation ID, without the code. An occasional mismatch may be noise. A sudden run of them is either an attack in progress or a configuration change somewhere, and both deserve someone's attention quickly.