skip to content

Why must an OpenID Connect relying party check that an ID token's aud claim contains its own client_id?

level: middleimportance: must knowfreq 66%

answer

  1. signed by, versus issued for
  2. one issuer signs for many clients
  3. the claim that names the addressee
  4. string or array, exact match either way
  5. azp is a SHOULD, aud is a MUST

basics

~20 s

The 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 s

In 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
json
{
  "iss": "https://id.college.example",
  "sub": "248289761001",
  "aud": ["s6BhdRkqt3", "enrolment-reporting"],
  "azp": "s6BhdRkqt3",
  "iat": 1757926800,
  "exp": 1757930400
}

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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.