ASVS 5.0 V10.5.3 (Level 2) says the client must reject authorization server metadata if the issuer value inside it does not exactly equal the issuer URL the client was configured to expect. The comparison is a plain string match.
Why it matters
Clients often discover endpoints by fetching /.well-known/openid-configuration. If a client accepts whatever that document says, an attacker who controls a different server, or redirects the fetch, can make the client talk to their endpoints while the document claims to be your provider. This is a mix-up style attack, and the issuer check is the defence.
How to implement it
Pin the expected issuer in configuration, fetch metadata from that URL only, and compare.
EXPECTED_ISSUER = "https://login.example.com/realms/prod"
def load_metadata():
url = EXPECTED_ISSUER + "/.well-known/openid-configuration"
meta = http_get_json(url, allow_redirects=False, timeout=5)
if meta.get("issuer") != EXPECTED_ISSUER:
raise ConfigError("issuer mismatch in discovery document")
return meta
Do not normalize, trim slashes or compare case insensitively, because near matches are how bypasses happen. Also check that the iss claim in ID Tokens equals the same pinned value.
Pinned values like this are worth protecting in review. A pull request gate is one place to catch risky configuration changes before they merge:
Click through it at your own pace, or open it full screen.
Static configuration versus discovery
The safest setup configures all endpoints by hand and skips discovery. If you use discovery for convenience, fetch it once at startup or on a long refresh interval, from the pinned issuer URL only, and apply the exact issuer check every time. Keep the JWKS endpoint from the same validated document, and require that it uses https and the same host family you expect.
Multi tenant applications
When your application supports many customers, each with their own provider, the pinned issuer is per tenant. Store it in the tenant configuration, restrict who can change it, and log changes. Do not take the issuer from a login hint typed by the user, because that lets an attacker add their own server to your trust list.
def issuer_for_tenant(tenant_id):
tenant = db.get_tenant(tenant_id)
if not tenant or not tenant.issuer.startswith("https://"):
raise ConfigError("no issuer configured")
return tenant.issuer
Authorization responses
RFC 9207 adds an iss parameter to the authorization response itself. If your provider sends it, compare it with the pinned issuer before using the code. It helps against mix-up attacks when a client talks to more than one provider.
How to test it
Point a test client at a mock discovery endpoint that returns a different issuer. Startup or login should fail. Test trailing slash, different case and different scheme variants of the issuer, each of which should fail. Confirm the client does not follow redirects for the discovery fetch.
Evidence
Keep the unit test with the mismatched issuer and the configuration showing the pinned value per environment. For a multi tenant app, add the access control rule on who can edit the issuer. Reviewers can then confirm both the check and its protection.
Common mistakes
- Building the issuer from the discovery response itself.
- Loose comparisons.
- Letting users supply the issuer URL in a multi tenant app without an allow list.
Quick check
Should a trailing slash difference be tolerated?
No. The requirement is an exact match, and tolerance for near matches invites bypasses.