Why must an OpenID Connect relying party check that an ID token's aud claim contains its own client_id?
answer
- signed by, versus issued for
- one issuer signs for many clients
- the claim that names the addressee
- string or array, exact match either way
- azp is a SHOULD, aud is a MUST
basics
~20 sThe aud claim is what says an ID token was issued for this client. A valid signature proves only which provider minted it, so skipping the audience check accepts a token minted for a different client registered at the same issuer.
solid answer
~40 sIn OpenID Connect the `aud` claim is REQUIRED and MUST contain the relying party's own `client_id`. It may be a single string in the common one-audience case, or an array that also names others, and the client must reject a token that does not list it or that lists audiences it does not trust. Without that check, anything the provider signed will pass: an attacker who registers their own client at the same provider can take an ID token issued to that client for a victim and post it to the vulnerable relying party, which sees a good signature, a matching `iss`, and logs the attacker in as the victim. Where several audiences are present the specification says the client SHOULD also verify that `azp` is present and equal to its own `client_id`.
code
json · 8 lines{
"iss": "https://id.college.example",
"sub": "248289761001",
"aud": ["s6BhdRkqt3", "enrolment-reporting"],
"azp": "s6BhdRkqt3",
"iat": 1757926800,
"exp": 1757930400
}go deeper
Recall that aud says who the ID token was issued for, and that your own client_id has to be in it before you believe anything else in the token.
Explain the mechanics: one signing key covers every client at a provider, aud can be a string or an array, and comparison is exact and case-sensitive.
Walk the substitution attack end to end, including why iss still matches, and say what a code review should look for in a hand-rolled validation routine.
Treat it as an estate rule: every service that consumes an assertion states which audiences it accepts, and the list is reviewed when a client is registered, not when an incident happens.
## Why the claim exists at all An ID token is an assertion, and an assertion is always made *to* someone. The `aud` claim is where OpenID Connect writes down who that someone is. It is REQUIRED in every ID token, and the rule is specific: `aud` **MUST contain the OAuth 2.0 `client_id` of the relying party** as an audience value. It may carry other audience values too, and in the common case of a single audience it is a plain string rather than an array — so a validation routine has to accept both shapes. The reason this is the leaf's flagship check is that the mistake is so easy to make and so quiet. A verification routine that checks a signature and an expiry looks finished. It is not. ## What the signature actually proves A signature verified against the issuer's published key proves one thing: this provider minted these bytes and nobody has altered them since. It does not say who the bytes were minted *for*. At any provider where more than one client is registered — which is every provider of consequence — the same signing key stands behind every client's ID tokens. Trusting the signature alone therefore means trusting every token that provider has ever issued, to anybody. ## The attack the check prevents 1. The attacker registers their own client at the same provider the target relying party uses. This is usually self-service and costs nothing. 2. A victim signs in to the attacker's application, which is a normal, consented login. The attacker now holds a valid ID token whose `sub` is the victim's and whose `aud` is the attacker's `client_id`. 3. The attacker posts that ID token into the target relying party's login callback. 4. The target checks the signature (good), checks `iss` (matches — same provider), and skips `aud`. It reads `sub`, finds the victim's account, and creates a session for it. Nothing was forged. The only failure was accepting a statement addressed to somebody else. This is why the audience check is not a hardening step to add later; it is the step that makes the assertion mean anything. ## The full rule, with its strengths | Check | Strength | What it rules out | |---|---|---| | `iss` exactly matches the configured issuer | MUST | a token from a different provider entirely | | `aud` contains this client's `client_id` | MUST | a token issued to a different client | | no untrusted extra audience values | MUST reject if present | a token deliberately shared with a party you do not trust | | with several audiences, `azp` present | SHOULD | ambiguity about which party the token was issued to | | `azp`, when present, equals this `client_id` | SHOULD | a token authorised for another party | The strength column matters. `azp` is only needed when extensions beyond the core specification are in use, and implementations not using those extensions are encouraged to ignore it when it appears. Writing `azp` up as a mandatory check teaches a false certainty; writing the audience check down as optional teaches a vulnerability. There is also an ordering detail worth knowing: in the specification's numbered validation sequence, the issuer match and the audience check come **before** signature validation. That ordering will not change which tokens are acceptable — all the checks must pass — but it is a useful corrective to the instinct that verification begins and ends with the signature. ## Getting the shapes right in code Two implementation traps recur. The first is the string-or-array shape: a routine that assumes an array throws on a conformant single-audience token, and a routine that assumes a string silently compares against a rendered array and never matches. Normalise to a list first. The second is comparison style: audience values are case-sensitive strings compared for exact equality, not prefixes, not substrings, not anything normalised. ## What a good answer sounds like Say that the signature identifies the minter and `aud` identifies the addressee, give the same-issuer substitution attack in two sentences, and state the rule at its real strength: `aud` MUST contain your `client_id`, extra untrusted audiences are grounds for rejection, and `azp` is a SHOULD that only matters in specific cases.
- An ID token's aud lists your client_id and one other value you have never heard of. What does the specification expect?Reject it. The rule is not only that your `client_id` appears: the client must also reject a token that carries additional audiences it does not trust. An unknown co-audience means the same assertion was addressed to a party outside your trust decision, and you cannot reason about what that party will do with it.
- What is azp for, and when should a client check it?`azp` names the party the ID token was issued to, and when present it contains that party's `client_id`. It is only needed where the sole audience differs from the authorized party, which happens with extensions beyond the core specification. With multiple audiences the client SHOULD verify `azp` is present, and SHOULD verify its value is its own `client_id` — a SHOULD, not a MUST, and implementations using no such extension are encouraged to ignore it.
- Does comparing the iss claim make the audience check unnecessary?No, and they defend against different things. `iss` rules out a token from another provider; `aud` rules out a token from *this* provider issued to another client. The substitution attack keeps `iss` correct on purpose — same provider, same key, different addressee — so the issuer match passes and only the audience check stops it.
saying these in an interview costs you the question
- If the signature verifies, the ID token is valid for us.
- aud is always a single string, so compare it directly.
- azp is a mandatory check on every ID token.
- Only the issuer matters, since we trust that provider.
- An extra audience value is harmless; ignore what you do not recognise.
- An attacker cannot get a signed token from our provider.