Skip to content
KEDBYTE
How Identity Works
Chapter
41

OpenID Connect

Part IV · Identity Between Systems|12,944 words|about 56 min read|Volume 4

41.0 What this chapter gives you#

  1. You will be able to explain why a system built only on OAuth 2.0 cannot tell you who a user is, and name the exact step where the reasoning breaks.
  2. You will be able to walk through the confused-deputy attack on a login button with real parties and messages, and say which single field in an ID token stops it.
  3. You will be able to read an ID token, name every claim in it, and say which are required by OpenID Connect Core 1.0 and which are conditional on what was asked for.
  4. You will be able to carry out the thirteen validation steps of Core section 3.1.3.7 in order, and explain what an attacker gains from each step you skip.
  5. You will be able to compute an at_hash by hand from an access token, check one you have been given, and say what a mismatch means.
  6. You will be able to read a .well-known/openid-configuration document field by field, and spot a provider whose document contradicts the specification.
  7. You will be able to write a correct key-fetching and caching rule for a JWKS endpoint, including what to do when a token names a key identifier you have never seen.
  8. You will be able to choose between putting a claim in the ID token and fetching it from UserInfo, and defend the choice on size, freshness and privacy grounds.
  9. You will be able to explain the difference between front-channel and back-channel logout, say why one of them has been failing in browsers since about 2020, and design around it.
  10. You will be able to explain what a pairwise subject identifier is, calculate one, and say which privacy problem it solves and which it does not.

OpenID Connect is a thin layer of rules written on top of OAuth 2.0. It introduces one new scope value, one new endpoint, one new metadata document and one new kind of token, and on that small foundation sits nearly every “Sign in with” button on the public internet. When you press one of those buttons on a phone or a laptop, the odds are very high that the exchange behind it is OpenID Connect.

The layer exists because of a specific gap. OAuth 2.0, published as RFC 6749 in October 2012 and covered properly in the chapter before this one, is a protocol for delegating access. It lets you tell a company that some other application may read your calendar or post on your behalf. It is extremely good at that. What it does not do, anywhere in its text, is tell the application who you are. The industry used it for login anyway, for about four years, by inventing a private convention at each provider, and the results were bad in a way that took a long time to become visible.

This chapter is about the layer that closed that gap. The central object is the ID token: a small signed statement, addressed to one named application, that says which user just authenticated, at which provider, at what time, and by what means. Everything else in OpenID Connect exists to make that statement findable, checkable, revocable and, where the provider chooses, privacy-preserving. The specification is precise about all of it, and the precision is the point, because the failures in this area are almost never cryptographic. They are failures to check one field.

We will use one worked example throughout. A person called Amara Diallo signs into an online bookshop, Pagewell Books, using her account at an identity provider called Larkspur ID. Every value in it is real in the sense that matters: the encodings are genuine encodings of the JSON shown, and the at_hash and c_hash values are the actual SHA-256 half-hashes of the token and code strings printed beside them.

The plain version#

The permission slip and the sealed letter#

Imagine a town with one big library and a row of shops along the road outside it.

The library, Larkspur, knows its members. Each member has a card, a number and a photograph on file, and to prove they are a member they say a password at the desk and then hold their thumb to a small reader. The library is quite sure who is standing in front of it.

The library also lends things. A member can ask the desk for a permission slip: a small printed note saying “the bearer of this slip may take three books from the local history shelf, until four o’clock this afternoon”. The slip is deliberately impersonal. It carries no name, because the whole point is that the member can hand it to somebody else, a research assistant, a courier, a friend, and that person can collect the books. The library will honour it from whoever presents it, until four o’clock.

That slip is an access token, and the arrangement producing it is OAuth. It lets a member say “this app may read my reading list” without giving the app their password.

Now the shops. Pagewell Books is a bookshop on the same road, and it would like to let library members create accounts without filling in a form. So Pagewell puts a sign in the window: “Sign in with Larkspur”.

Here is the arrangement Pagewell invents. When a customer clicks the sign, Pagewell sends them next door to ask the library for a permission slip. The customer comes back holding the slip. Pagewell takes it, walks over to the library desk, waves the slip and asks “whose slip is this?” The library, honestly, answers “that slip belongs to member 4471, Amara Diallo”. Pagewell says “wonderful”, and logs the customer in as Amara.

For a while, this works. Amara is delighted. Pagewell is delighted. And it is completely broken, in a way we can see if we follow one more person down the road.

The shop that got it wrong#

Two doors down is Quillbox, a stationery shop with a loyalty scheme. Quillbox also has a “Sign in with Larkspur” sign, and Amara has used it, so Quillbox holds a permission slip of Amara’s, quite legitimately. Amara gave it to them on purpose. That is the entire design of the slip.

Nothing stops the manager of Quillbox from walking into Pagewell, presenting Amara’s slip, and saying “sign me in”. Pagewell does exactly what it always does. It carries the slip to the library and asks whose it is. The library answers, honestly and correctly, “member 4471”. And Pagewell logs the manager of Quillbox in as Amara.

Nobody forged anything. The library did not lie. Pagewell’s code did not crash. The slip was genuine, the answer was true, and the wrong person is now reading Amara’s order history.

The mistake is a single confusion, and it is worth saying very slowly. A slip proves that somebody, at some point, was given permission. It does not prove that the person holding it now is the person who was given it. Pagewell treated evidence of permission as evidence of identity, and those are not the same kind of evidence at all.

This has a name. In 1988, an engineer called Norm Hardy wrote a two-page paper for a computing journal titled “The Confused Deputy”, describing a compiler that was tricked into overwriting a billing file because it was acting with two different authorities at once and had no way to tell them apart. The name stuck. Pagewell is a confused deputy: it is acting on the library’s authority and on the customer’s authority in the same breath, and it cannot tell which one it is using.

The sealed letter#

The fix is not clever. It is a second piece of paper.

When Pagewell sends the customer next door, it now asks for two things: a permission slip, as before, and a sealed letter addressed to Pagewell. The library writes the letter at the moment the member proves who they are at the desk, and it says, in fixed and predictable wording:

The letter is from Larkspur Library. It is for Pagewell Books, by name. It is about member 4471. The member proved themselves at nineteen minutes past nine this morning. They did it with a password and a one-time code. The letter stops being any use at twenty-five minutes past nine. And the code word that Pagewell whispered when it sent the customer over was “n-0S6”. At the bottom is the library’s seal.

That letter is an ID token, and every clause in it is doing a job.

It says who wrote it, so Pagewell cannot be fooled by a letter from some other library.

It says who it is for. This is the clause that kills the Quillbox attack stone dead. If the manager of Quillbox walks in with Amara’s letter, it will be addressed to Quillbox, and Pagewell must refuse it. A letter addressed to somebody else is not evidence about the person in front of you, and in the old arrangement the slip carried no address at all, which is exactly why it could be moved between shops.

It says who the member is, using the library’s own membership number rather than a name or an email address, because names and email addresses are changed and reused and numbers are not.

It says when the member proved themselves and how, which lets Pagewell say “you last proved yourself four hours ago, and this is a payment page, so please do it again”.

It carries the fresh, unguessable code word that Pagewell chose for this one sign-in and remembered, so the letter cannot be one from last Tuesday kept in a pocket and replayed.

It has a time on it, because a letter that never expires is a letter that circulates for ever. And it has a seal, so Pagewell can tell that the library wrote it and that nobody has scratched a word out and written a different one in.

The noticeboard by the door#

A seal is only useful if you know what the genuine seal looks like. The library solves this in the dullest possible way. It keeps a glass noticeboard by its front door, and pinned to that noticeboard are pictures of the seals it is currently using. Each seal has a small reference number stamped in its corner, and each letter says in its top line which seal was used.

So Pagewell’s routine is: read the top line of the letter, note the seal number, look at the noticeboard, find the seal with that number, and compare.

Two things follow from this that will matter later.

The library changes its seals from time to time. When it does, it pins the new one up beside the old, starts using the new one, and leaves the old picture up for a while so that letters written yesterday can still be checked.

And Pagewell does not walk to the noticeboard for every letter. It photographs the board and keeps the photograph in a drawer, going back for a fresh look only when it sees a seal number it does not recognize. That is the whole caching rule, and getting it slightly wrong produces two failures we will meet later: a shop that visits the board a thousand times a second, and a shop that never goes back and so rejects every letter after the library changes its seals.

There is one more small sign, screwed to the wall beside the library’s front door, in a standard shape that every library in the country uses. It lists, in the same order every time, where the desk is, where the slips are issued, where the noticeboard is, and which kinds of letter this library is willing to write. A new shop opening on the road reads that one sign and knows how to do business with the library without asking anybody. That is discovery.

A worked example: Amara signs into Pagewell#

It is twenty past nine in the morning on Monday the seventeenth of August 2026. Amara opens Pagewell Books and clicks “Sign in with Larkspur”.

Pagewell does three small things before anything leaves the machine. It makes up a fresh unguessable code word for this sign-in and stores it. It makes up a second unguessable value whose only job is to make sure the customer who comes back is the same one who left. And it reads, off the library’s standard sign, where the library’s front desk is.

Amara’s browser goes to Larkspur. Larkspur does not know or care what Pagewell wants; it does its own job, which is to be sure this is Amara. She types her password and taps a six-digit code from her phone, at nineteen minutes past nine. Larkspur then shows her a short screen: Pagewell Books would like to know who you are and see your email address. She agrees.

Larkspur sends her browser back to Pagewell carrying a short one-time voucher, and nothing else. Not the slip, not the letter. A voucher.

Pagewell now talks to the library directly, machine to machine, with no browser in the middle. It hands over the voucher and its own shop credentials, and gets back three things: a permission slip, a note saying the slip is good for an hour, and the sealed letter.

Pagewell checks the letter, in order, before it believes a single word of it. Is the seal genuine, according to the picture on the noticeboard with the matching number. Is it from Larkspur. Is it addressed to Pagewell. Has it expired. Does it quote the code word Pagewell chose at the start. Only when all of that passes does Pagewell read the membership number off it and log Amara in.

Amara’s email address was not in the letter, because Larkspur keeps its letters small. So Pagewell takes the permission slip to the library desk and asks for the member’s email address. The desk answers with the address and, importantly, with the membership number again, and Pagewell checks that this number matches the one in the letter. If it did not match, Pagewell would throw the answer away, because the letter is the one document that says who this is.

That is a complete OpenID Connect sign-in, described without a single technical word. The rest of this chapter is the same story with the real names, and then all the places where the story above is not quite true.

Telling the shops she has gone home#

At six o’clock Amara tells the library she is leaving and wants to be signed out everywhere. The library can do two things.

It can telephone every shop it wrote a letter to today and say “member 4471 has gone home, forget her”. This is reliable, but the library must keep a list of who it wrote to, and each shop needs a telephone the library can actually reach, which a shop behind a locked back door does not have.

Or it can ask Amara to walk past each shop’s window on her way out, so that each shop sees her go. This needs no telephone. It also needs her to actually walk past every window, and it stops working if she takes a taxi from the door, or if the shops have put up blinds. The blinds are real: browsers began blocking exactly this kind of glance between different websites around 2020, and have been getting stricter since.

Where the plain version stops being true#

The letter is sealed against changes, not against reading#

The word “sealed” does a lot of quiet lying in the plain version. It suggests an envelope.

An ID token is not in an envelope. In the overwhelmingly common case it is a signed JSON Web Token, and the claims inside it are encoded, not encrypted. Anybody who holds the token can read every field of it with a tool that every computer already has. The signature stops modification and proves origin; it hides nothing at all. Chapter 39 covers that encoding in detail and this chapter will not repeat it.

The honest version: everything you put in an ID token is visible to whoever holds the token, and that includes the user’s own browser, any proxy in the path, your server’s access logs and your error tracker. OpenID Connect Core 1.0 does define encrypted ID tokens, and the registration parameters for them exist, but the very large majority of deployments sign and do not encrypt. If a claim would embarrass you in a log file, it should not be a claim.

“Who the user is” means “who this provider says they are”#

The plain version says the letter tells Pagewell who the customer is. It does not, quite. It tells Pagewell who Larkspur believes the customer is, which is a smaller claim than it sounds.

An ID token is a statement by one issuer about one of its own accounts. It carries no evidence about the world. If the provider’s account recovery process is weak, the ID token will be issued to whoever won that process. If the provider allows an account to be created with an unverified email address, then an email claim in the token is a string that somebody typed, not a fact.

The honest version: OpenID Connect gives you a trustworthy channel for an assertion whose trustworthiness is entirely a property of the issuer. The protocol is only as good as the identity proofing behind it, and the protocol has nothing to say about that proofing. A relying party that accepts sign-in from any provider is not deciding a technical question; it is deciding whose account-opening standards it is willing to inherit.

The order of the checks is the security, and only one of them is cryptographic#

The plain version lists Pagewell’s checks as though they were a formality after the seal. In practice the seal is the least interesting of them, because the seal is done for you by a library function and the rest usually is not.

Consider what each omission buys an attacker. Skip the audience check and any token issued to any other client at the same provider will log its holder in at yours, which is the Quillbox attack in modern clothes. Skip the issuer check and a token from a provider you have never heard of, perfectly signed by its own key, is accepted. Skip the nonce check and a token captured from an earlier session can be replayed. Skip the expiry check and a token from last year works. Skip the at_hash check where it is required and an attacker can pair a genuine ID token about themselves with an access token belonging to somebody else.

The honest version: the signature check is one of at least six checks, it is the one your library almost certainly does by default, and it is the one attackers almost never need to beat. Every widely reported OpenID Connect failure of the last decade has been a missing non-cryptographic check or a mis-scoped identifier.

A different number at every shop is not anonymity#

The plain version says the library can give each shop a different membership number, so the shops cannot compare notes. This is true and it is worth doing, and it is much weaker than it sounds.

Pairwise identifiers stop naive correlation on the identifier alone. They do not stop correlation on anything else, and there is a great deal of anything else. If both shops receive the same email address, the email address is the correlation key. If both shops receive a full name and a birth date, that pair is very close to a correlation key on its own. And the identity provider itself sees every sign-in at every shop, so pairwise identifiers move the correlation power from the shops to the provider rather than removing it.

The honest version: a pairwise subject identifier is a defence against relying parties colluding, not against the identity provider, and it is undone by any other stable claim you release alongside it. It is a real privacy control with a narrow blast radius, and describing it as anonymity is marketing.

The noticeboard changes while you are reading it#

The plain version has a tidy rule: cache the board, go back when you see an unfamiliar number. Real key rotation is messier in three ways.

Not every provider puts a key identifier on its tokens, though the well-run ones do. If the token does not name a key, a verifier must try every key in the set, which quietly breaks the useful property that an unknown identifier is a signal to refresh.

The refresh itself is a denial-of-service lever pointed at your own infrastructure and at the provider’s, because a token with a random key identifier, sent a thousand times a second, makes a naive verifier fetch the key set a thousand times a second. Serious implementations rate-limit the refresh, and the specification does not say what the limit should be.

And the transition window is a policy, not a rule. The specification says a provider should retain recently retired signing keys “for a reasonable period of time” and coordinate that with cache lifetimes. Reasonable is not defined, and providers differ.

The honest version: key rotation in OpenID Connect is a convention held together by a kid value and good manners. It works well in practice and it has no enforcement anywhere in the protocol.

Logging out does not log out#

The plain version treats sign-out as a message-delivery problem. The deeper problem is that in many deployments there is nothing to deliver a message about.

An access token, once issued, is usually valid until it expires whatever anybody says afterwards, and a relying party that has minted its own local session cookie after a successful sign-in has created a session that the identity provider has no handle on at all. Logging out at the provider ends the provider’s session. It does not, by itself, end the fifty local sessions that were created from it.

The honest version: OpenID Connect logout is a notification protocol, not a revocation protocol. It tells relying parties that something happened; whether anything is actually torn down depends entirely on code each relying party had to write. And the front-channel variant, which needs a browser to load a small page from every relying party in turn, has been degrading for years as browsers block third-party state. The specifications say so themselves, in as many words.

The provider you think you are talking to#

The plain version has one library on one road. Real deployments have many providers, and the relying party often decides which one to use based on something the user typed.

That opens a class of attack in which a relying party is persuaded to send a user to one provider and then accepts a response as though it came from another. The defence is to check the issuer everywhere, including on the redirect back from the authorization endpoint, which is what the iss response parameter of RFC 9207, published in March 2022, was added for. Chapter 43 handles federation and provider mix-up properly.

The honest version: OpenID Connect is safe when one relying party talks to one known issuer whose configuration it fetched itself. Every extra provider, and every piece of user-supplied discovery input, adds a way to confuse the relying party about which issuer it is dealing with.

The technical version#

What OpenID Connect adds to OAuth 2.0#

OpenID Connect Core 1.0 was approved as a final specification by the OpenID Foundation membership on 26 February 2014. Its authors are Nat Sakimura, John Bradley, Michael B. Jones, Breno de Medeiros and Chuck Mortimore. The document that governs deployments today is titled “OpenID Connect Core 1.0 incorporating errata set 2” and is dated 15 December 2023; the companion discovery document carries the same errata set and the same date. In 2024 nine of the OpenID Connect documents were published as international standards, with Core becoming ISO/IEC 26131:2024 and Discovery becoming ISO/IEC 26132:2024.

Set against OAuth 2.0, the additions are few enough to list. There is one new scope value, openid, whose presence in an authorization request is what makes the request an OpenID Connect request at all; Core section 3.1.2.1 states that OpenID Connect requests must contain it. There is one new token, the ID token of Core section 2: a signed JSON Web Token, issued to exactly one client, stating that an authentication happened. There is one new endpoint, UserInfo, in Core section 5.3, which returns claims about the user in exchange for an access token. There is one new metadata document, at .well-known/openid-configuration, defined in Discovery 1.0 section 4.

There are two new request parameters that matter for security, nonce and max_age, and several for controlling the experience: prompt, display, ui_locales, login_hint, acr_values and id_token_hint.

And there is a vocabulary. OAuth calls the parties “client” and “authorization server”. OpenID Connect calls them Relying Party (RP) and OpenID Provider (OP), and calls the human the End-User. The words are not decoration; they mark the change of purpose. A client wants access to a resource. A relying party wants to know who it is talking to.

That is the whole of the layer. Everything else in the specification family is refinement: logout, session management, dynamic registration, request objects, aggregated claims, and profiles for particular industries.

The confused deputy, stated precisely#

Here is the failure in protocol terms, without the bookshop.

An OAuth 2.0 access token is a bearer credential that is scoped to a resource, not to a client. RFC 6749 gives the resource server no general way to determine which client an access token was issued to, and the token itself carries no field that a naive relying party is obliged to check. The 2014 study by Eric Chen, Yutong Pei, Shuo Chen, Yuan Tian, Robert Kotcher and Patrick Tague, “OAuth Demystified for Mobile Application Developers”, presented at the ACM Conference on Computer and Communications Security, put the consequence in one sentence: because access tokens in OAuth 2.0 are not bound to the relying parties they are issued to, it is impossible to verify whether a user is using an access token to sign on to the same relying party that obtained it.

The broken login pattern, which was standard practice at several large providers between roughly 2010 and 2014, works like this. The relying party obtains an access token for the user. It then calls a provider endpoint, commonly named something like /me or /userinfo or /tokeninfo, passing the access token. The endpoint returns a user identifier. The relying party treats that identifier as the identity of the person in front of it and creates a session.

The attack is three steps.

1. Attacker runs any app the victim has authorized, or
   steals a token by any of the ordinary means, and so
   holds a valid access token AT(victim, app-X).

2. Attacker submits AT(victim, app-X) to the login
   endpoint of relying party RP-Y, which accepts an
   access token from the client side.

3. RP-Y calls provider /me with AT(victim, app-X).
   Provider truthfully answers "user 4471".
   RP-Y creates a session for user 4471.

Nothing is forged. The provider’s answer is correct. The defect is that the relying party asked a question whose answer does not depend on who is asking. This is precisely Norm Hardy’s confused deputy, published in the ACM SIGOPS Operating Systems Review, volume 22, issue 4, in October 1988: a component acting with authority from two sources, unable to say which authority it is exercising.

The empirical picture was ugly. Chen and colleagues examined 600 top-ranked Android applications, found that more than 24 per cent of them used OAuth, and of the 149 applications that did, 89 of them, or 59.7 per cent, were incorrectly implemented and vulnerable. Two years earlier, Rui Wang, Shuo Chen and XiaoFeng Wang had presented “Signing Me onto Your Accounts through Facebook and Google: a Traffic-Guided Security Study of Commercially Deployed Single-Sign-On Web Services” at the 2012 IEEE Symposium on Security and Privacy, finding logic flaws of this family in commercially deployed single sign-on across major providers and relying parties.

An ID token fixes it with one field. The token carries aud, the audience, which contains the client identifier of the relying party the token was issued to. A relying party must reject any ID token whose aud does not contain its own client identifier. The credential is now bound to the consumer, and moving it to another consumer makes it invalid rather than merely suspicious.

Two properties come with it. The ID token is delivered to the relying party’s back end through the token endpoint under client authentication, so a hostile app cannot simply hand one over from the client side, and it is signed, so it can be checked without an extra network call.

The ID token, and how it differs from an access token#

Core section 2 defines the ID token as a security token containing claims about the authentication of an End-User by an authorization server, and requires that it be represented as a JWT. The claims used in every flow are these.

Claim Status What it carries
iss REQUIRED Issuer identifier, an https URL
sub REQUIRED Subject, max 255 ASCII chars
aud REQUIRED Client id of the RP
exp REQUIRED Expiry, seconds since 1970
iat REQUIRED Issued-at, seconds since 1970
auth_time Conditional When the user authenticated
nonce Conditional Echo of the request nonce
acr OPTIONAL Authentication context class
amr OPTIONAL Methods used, e.g. pwd, otp
azp OPTIONAL Authorized party

The two conditional entries are conditional in a precise way. auth_time is required when a max_age request was made or when auth_time was requested as an essential claim, and optional otherwise. nonce must be present if a nonce was sent in the authentication request, and the relying party must check that it matches.

Core section 2 also fixes the exact meaning of sub: “a locally unique and never reassigned identifier within the Issuer for the End-User”, and it “MUST NOT exceed 255 ASCII characters in length”. Locally unique is the important half. A sub value is unique within one issuer and carries no guarantee of uniqueness anywhere else, which is why the identifier you store is the pair of iss and sub, never sub alone.

azp names the party the token was issued to when that differs from the audience. Core states that it only occurs in practice when extensions beyond the specification are in use, and encourages implementations not using them to leave it out and ignore it when it appears.

Now the comparison that the whole chapter turns on.

Question ID token Access token
Audience The relying party The resource server
Purpose Assert an authentication Authorize an API call
Format Always a JWT Opaque or JWT
Read by The RP, once The API, many times
Sent to APIs Never Yes, as a bearer token
Lifetime Minutes, single use Minutes to hours, reused

The row that produces the most production bugs is the fifth. An ID token is not an API credential. It is a receipt for an authentication event, consumed once by the party it is addressed to, and then discarded or exchanged for a local session. Sending an ID token to a back-end API as though it were an access token is a category error that works, right up to the moment the API is called by a different relying party’s token. Conversely, an access token is not evidence of identity for anyone. RFC 9068, published in October 2021, defines a standard JWT profile for access tokens with an at+jwt type header, precisely so that the two kinds of JWT cannot be confused for one another.

Validating an ID token in the order the specification gives#

Core section 3.1.3.7 lists thirteen numbered steps for validating an ID token received from the token endpoint in the authorization code flow. They are not decorative and the order is deliberate. Written out as behaviour, with the specification’s own requirement levels:

  1. If the ID token is encrypted, decrypt it with the keys and algorithms registered for that purpose. If encryption was negotiated at registration time and the token is not encrypted, the relying party should reject it.
  2. The issuer identifier for the provider, normally obtained during discovery, must exactly match the value of the iss claim. Exactly means string equality, not “the same host”.
  3. The relying party must validate that aud contains its own client_id as an audience. The claim may be an array with more than one element. The token must be rejected if the relying party is not listed, or if the token contains additional audiences the relying party does not trust.
  4. If extensions are in use that result in azp being present, it should be validated as those extensions specify.
  5. That validation may include checking that when azp is present, the relying party’s own client_id is its value.
  6. When the token arrives by direct communication with the token endpoint, which it does in the code flow, TLS server validation may be used to validate the issuer in place of checking the signature. All other ID tokens must have their signature validated per the JSON Web Signature specification, using the algorithm in the alg header, and using keys provided by the issuer.
  7. The alg value should be the default of RS256 or the algorithm the relying party registered in id_token_signed_response_alg.
  8. If alg is a MAC-based algorithm such as HS256, the UTF-8 octets of the client_secret for the client_id in aud are used as the validation key.
  9. The current time must be before the time in exp.
  10. The iat claim can be used to reject tokens issued too far from the current time, which limits how long nonces must be stored; the acceptable range is relying-party specific.
  11. If a nonce was sent in the authentication request, a nonce claim must be present and its value checked against the one sent. The relying party should check the nonce for replay.
  12. If acr was requested, the relying party should check that the asserted value is appropriate.
  13. If auth_time was requested, by a specific claim request or by using max_age, the relying party should check it and require re-authentication if too much time has elapsed.

Three of those deserve comment.

Step 6 is the specification’s one concession to convenience, and it is regularly misunderstood. In the code flow the ID token arrives over a TLS connection that the relying party opened to the token endpoint named in the discovery document, under client authentication, so the transport already establishes who you are talking to and the signature check may be skipped. It is still better to check it, most libraries do, and the permission evaporates the moment a token reaches you by any other route.

Step 3 must be enforced in both directions. Rejecting a token that does not name you is obvious; rejecting one that names you and also names audiences you do not trust is the part that gets skipped, and it matters because a multi-audience token can be replayed at the other audience.

Step 11 is where many implementations quietly do nothing. Sending a nonce and never comparing it buys nothing at all.

Nonce, at_hash and c_hash: binding a token to a request#

Three mechanisms tie an ID token to the particular exchange it belongs to. They are easy to describe and easy to leave out.

The nonce is a value the relying party generates, sends in the authentication request, and finds echoed in the ID token. Core section 3.1.2.1 defines it as OPTIONAL in the authorization code flow and section 3.2.2.1 defines it as REQUIRED in the implicit flow, and both say that sufficient entropy must be present in nonce values to prevent attackers from guessing them. Its purpose is stated as associating a client session with an ID token and mitigating replay attacks. In the code flow, PKCE and the one-time code together already frustrate most replay; chapter 40 handles PKCE. The nonce remains worth sending because it is the only mechanism that binds the ID token itself to the session that asked for it, and because a relying party that always sends one has a uniform rule instead of a conditional one.

The at_hash binds an ID token to an access token delivered alongside it. Core section 3.1.3.6 defines it as the base64url encoding of the left-most half of the hash of the octets of the ASCII representation of the access_token value, where the hash algorithm is the one used in the alg header of the ID token. For RS256 that means SHA-256, so you take the 32-byte digest, keep the first 16 bytes, and base64url-encode them.

Here is that computation with real values. The access token string is SlAV32hkKG_c8Xk1p9Qy7T.

access_token = SlAV32hkKG_c8Xk1p9Qy7T
SHA-256, first 16 bytes, in hex:
  f8 4f 15 ae 11 2c 0f 1a 14 fe 8d 35 94 6d b0 10
base64url of those 16 bytes:
  -E8VrhEsDxoU_o01lG2wEA
so at_hash = "-E8VrhEsDxoU_o01lG2wEA"

The c_hash does the same job for the authorization code, and Core section 3.3.2.11 defines it identically with the code value in place of the access token. With the code SplxlOBeZQQYbYS6WxSbIA the result is o1uBp9eSe3DsmScN0jYriA.

When each is required is a matter of which response type was used, and the rule is worth memorizing because it is the difference between a defence and a decoration.

Response type at_hash c_hash
code Optional Not applicable
id_token Not used Not applicable
id_token token REQUIRED Not applicable
code id_token Optional REQUIRED
code id_token token REQUIRED REQUIRED

The reason is mechanical. Whenever an ID token is issued from the authorization endpoint alongside another artefact, that artefact travels through the browser where it can be swapped. The hash claim is what lets the relying party detect the swap. In the plain code flow both artefacts arrive together over the back channel, so the binding is already there and at_hash is optional.

Discovery: the .well-known document, field by field#

OpenID Connect Discovery 1.0, by Nat Sakimura, John Bradley, Michael B. Jones and Edmund Jay, also carrying errata set 2 and dated 15 December 2023, defines two things: how to find an issuer from a user identifier, and how to find everything else from an issuer.

The first, in section 2, uses WebFinger with a link relation defined by the specification for OpenID Connect issuers, and is rarely deployed. Almost every real system knows its issuer from configuration.

The second, in section 4, is one of the most successful pieces of design in the family. Take the issuer URL, remove any trailing slash, append /.well-known/openid-configuration, and perform an HTTP GET. What comes back is a JSON document describing the provider.

Discovery section 3 marks six members REQUIRED:

Member What it gives you
issuer The exact iss string
authorization_endpoint Where to send the browser
jwks_uri Where the signing keys live
response_types_supported Which flows are offered
subject_types_supported public, pairwise or both
id_token_signing_alg_values_supported Which alg values are used

token_endpoint is described as REQUIRED unless only the implicit flow is used, which in practice means every modern deployment must publish it. userinfo_endpoint, registration_endpoint, scopes_supported and claims_supported are RECOMMENDED. A long tail is OPTIONAL, including response_modes_supported, grant_types_supported, acr_values_supported, the encryption algorithm lists, token_endpoint_auth_methods_supported, claims_parameter_supported, request_parameter_supported and the policy and terms-of-service URLs.

Some individual definitions repay reading closely.

issuer must be an https URL with no query or fragment, and must be identical both to the value returned by WebFinger, if issuer discovery is supported, and to the iss claim in ID tokens from that issuer.

jwks_uri must use https and holds the signing keys used to validate signatures from the provider. It may also hold encryption keys, and when both are present a use parameter is required on every key; the specification adds that using one key for both purposes is NOT RECOMMENDED, as it is less secure.

id_token_signing_alg_values_supported must include RS256. The value none may be supported but must not be used unless the response type returns no ID token from the authorization endpoint.

claims_supported is explicitly not a contract: it lists claims the provider may be able to supply, and for privacy or other reasons it might not be exhaustive. Treating it as a schema is a common mistake.

Discovery section 4.3 gives the validation rule, and it is one line that closes a whole class of attack: the issuer value returned must be identical to the issuer URL that was used as the prefix to /.well-known/openid-configuration to retrieve the document, and must also be identical to the iss claim value in ID tokens issued by that issuer. If you fetch configuration from one place and it claims to be somewhere else, you stop.

It is worth seeing what real providers publish. These three documents were fetched on 17 August 2026.

Field Google Microsoft Apple
subject_types_supported public pairwise pairwise
Signing algs RS256 RS256 RS256
Response types 8 listed 4 listed code only
end_session_endpoint absent present absent

Google’s document, at the issuer accounts.google.com, lists scopes_supported as exactly openid, email and profile, publishes eleven entries in claims_supported, and sets authorization_response_iss_parameter_supported to true, which is the RFC 9207 signal.

Microsoft’s common-tenant document is the interesting one, because its issuer value is a literal template with the braces present.

"issuer":
  "https://login.microsoftonline.com/{tenantid}/v2.0"

That is a template, not a URL, and it does not satisfy Discovery section 4.3 as written. It is a documented consequence of a multi-tenant endpoint where the real issuer is only known once you see which tenant the user came from, and it means a relying party using the common endpoint must implement issuer validation as a per-tenant rule rather than a string comparison. The specification is precise, and the largest deployments have edges it did not anticipate.

Apple’s document lists response_types_supported as code alone, subject_types_supported as pairwise, scopes of openid, email and name, and a claims_supported list carrying three claims of its own invention: is_private_email, real_user_status and transfer_sub. Nothing forbids provider-specific claims; building on them simply means you are no longer portable.

JWKS endpoints, key rotation and caching rules#

The jwks_uri returns a JSON Web Key Set: an object with a keys member holding an array of public keys, each in the JWK format of RFC 7517. Google’s key set, fetched on 17 August 2026, contained four RSA keys, every one of them marked "use": "sig" and "alg": "RS256", each with a 40-character hexadecimal kid. Trimmed to the fields that matter, one entry looked like this.

{
  "kty": "RSA",
  "kid": "49900291d36cec8c018f2e8af3ef235b8b18d3c9",
  "use": "sig",
  "alg": "RS256",
  "e": "AQAB",
  "n": "yYcQpXE3z8HRR3Vw7n5vQHCK-DM7kyfHd9Q17PZp82g6mB..."
}

The n value, the RSA modulus, is about 342 base64url characters and has been cut short here; a real key set carries it in full on one line.

Core section 10.1.1, “Rotation of Asymmetric Signing Keys”, describes the rotation procedure in a single paragraph that is worth having in front of you when you write the code. The signer publishes its keys in a JWK Set at its jwks_uri and includes the kid of the signing key in the JOSE header of each message. Keys are rolled over by periodically adding new keys to the set. The signer may begin using a new key at its discretion and signals the change through kid. The verifier knows to return to the jwks_uri to re-retrieve the keys when it sees an unfamiliar kid. And the set at the jwks_uri should retain recently decommissioned signing keys for a reasonable period of time to facilitate a smooth transition.

Section 10.2 covers encryption keys and adds the caching guidance explicitly, referring to the HTTP caching rules of RFC 7234 so that a party can safely cache the key set rather than re-retrieve it for every operation, and saying the cache duration should be coordinated with the issuance of new signing keys.

From those two paragraphs, plus operational experience, a correct client rule looks like this.

On startup:
  fetch jwks_uri once, keep the set in memory.

On each token:
  read kid from the JOSE header.
  if kid is known      -> verify with that key, done.
  if kid is unknown    -> refresh, subject to the rules
                          below, then verify once.
  if kid is absent     -> try every key of matching kty
                          and alg; log it as a defect.

Refresh rules:
  honour Cache-Control from the jwks_uri response;
  never refresh more than once per N seconds
    (N of 30 to 300 is usual practice);
  on refresh failure, keep serving from the old set;
  never fetch keys from a URL named inside the token.

Four remarks on that.

The last line keeps you out of the JWT header-injection family of attacks, in which a token names its own key or the place to fetch it. Keys come from the discovery document you fetched from the issuer you configured, and from nowhere else.

The rate limit exists because “refresh on unknown kid” is otherwise a remote lever on your outbound traffic and on the provider’s endpoint: an attacker sending tokens with random kid values generates one upstream fetch per request.

Serving from a stale set on refresh failure is deliberate. A provider’s key endpoint being briefly unavailable should not log out your entire user base, and keys valid a minute ago are almost certainly still valid.

Retention on the provider’s side is the mirror image. Remove a key at the instant you stop signing with it, and every token issued in the preceding minutes becomes unverifiable for any relying party whose cache misses. The specification’s “reasonable period of time” is doing a lot of work; good providers keep a retired key published for at least the maximum lifetime of anything signed with it.

UserInfo versus claims in the ID token#

There are two places a relying party can obtain claims about the user, and choosing between them is a real design decision rather than a matter of taste.

The UserInfo endpoint, Core section 5.3, is an OAuth 2.0 protected resource. The relying party calls it with the access token, normally as a bearer token in the Authorization header per RFC 6750, and the communication must use TLS. The response is JSON by default, and may instead be a signed or encrypted JWT, in which case the content type must be application/jwt. If both signing and encryption are requested, the response must be signed and then encrypted, producing a nested JWT.

Core section 5.3.2 attaches one hard requirement that is skipped constantly, and it is the same confused-deputy problem wearing yet another hat. The sub claim must always be returned in the UserInfo response, and the specification says: due to the possibility of token substitution attacks, described in Core section 16.11, the UserInfo response is not guaranteed to be about the End-User identified by the sub element of the ID token. The sub claim in the UserInfo response must be verified to exactly match the sub claim in the ID token; if they do not match, the UserInfo response values must not be used.

The reason is that the access token used to call UserInfo is not bound to the ID token: substitute a different access token and you get a truthful answer about a different person. The ID token is the authority on who signed in; UserInfo is a lookup that must be checked against it.

The trade-offs between the two locations are these.

Consideration ID token UserInfo
Extra round trip No Yes, one per fetch
Freshness Frozen at issue Current at call time
Size limits URL and header limits None in practice
Exposure Anyone holding token Only the caller
Needs access token No Yes, and it must be live

A practical rule that survives contact with production: put in the ID token only what you need to establish the session, which is iss, sub, the timing claims and any authentication-strength claims you enforce, and fetch everything else, especially anything that changes and anything personal, from UserInfo. The ID token is a receipt for an event, and events do not change; a display name is a fact about a person, and people change. The exception is the hybrid and implicit flows, where an ID token may be all you have before you must render something, which is why providers put a name and a picture URL in it: a convenience bought with permanent exposure of those values in logs and browser history.

Scopes, standard claims and the claims request parameter#

Core section 5.1 defines the standard claim set: name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, email, email_verified, gender, birthdate, zoneinfo, locale, phone_number, phone_number_verified, address and updated_at. It is a fixed vocabulary, which is the entire reason a relying party can accept sign-in from two providers without writing two mapping layers.

Section 5.4 defines four scope values that request bundles of those claims, and states that claims requested by these scopes are treated as voluntary claims.

Scope Claims requested
profile 14 profile claims
email email, email_verified
address address
phone phone_number and verified flag

The fourteen claims behind profile are name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale and updated_at. Scopes are coarse by design, and asking for profile to obtain a display name means also asking for a birth date and a gender, which is a consent problem and, under data protection law, a data minimization problem.

Section 5.5 provides the fine-grained alternative: the claims request parameter, a JSON object sent in the authentication request with two top-level members, userinfo and id_token, each naming individual claims and saying how badly you want them. A claim mapped to null is voluntary. A claim mapped to an object may carry essential set to true, or value to demand one specific value, or values to demand one of a list. The specification’s own example is this.

{
 "userinfo": {
   "given_name": {"essential": true},
   "nickname": null,
   "email": {"essential": true},
   "email_verified": {"essential": true},
   "picture": null,
   "http://example.info/claims/groups": null
 },
 "id_token": {
   "auth_time": {"essential": true},
   "acr": {"values":
     ["urn:mace:incommon:iap:silver"]}
 }
}

Three things to know before you reach for it.

Support is optional and advertised. A provider says whether it will honour the parameter through claims_parameter_supported in its discovery document, and a great many large providers, including Google as of August 2026, do not publish that member at all.

“Essential” is not “mandatory”. Core is explicit that marking a claim essential indicates to the provider that it is necessary for the relying party to perform its task, but does not oblige the provider to return it, and the relying party must still cope with its absence.

And the parameter is the only way to request combinations that scopes cannot express, which includes requesting auth_time or a particular acr in the ID token. If you enforce authentication strength, this is how you ask for it.

Subject identifier types, and the identifier you must never use#

Core section 8 defines two subject identifier types. A public identifier provides the same sub value to all clients. A pairwise identifier provides a different sub value to each client, so that clients cannot correlate the End-User’s activities without permission. A provider must list what it supports in subject_types_supported, and where more than one is listed a client may state a preference at registration with subject_type.

Section 8.1 sets the rules for calculating pairwise values. The provider must calculate a unique sub for each Sector Identifier. The value must not be reversible by any party other than the provider. Distinct sector identifiers must produce distinct subject identifiers. The algorithm must be deterministic. Any algorithm with those properties is permitted, and the specification offers as an example: concatenate the sector identifier, a local account identifier and a provider-held secret salt, and hash the result, giving sub equal to SHA-256 of sector_identifier concatenated with local_account_id concatenated with salt.

The sector identifier is the host component of the registered redirect_uri. A client with redirect URIs on several hostnames must register a sector_identifier_uri: an https URL pointing to a JSON file containing an array of redirect URI values that includes all the registered ones, whose host then becomes the sector identifier. That indirection lets a group of sites under common control share one pairwise identity, and lets a client change redirect domains without every user’s identifier changing underneath it.

Here is the calculation with real values, using the example algorithm, a local account id of user-88214 and a salt of 5eb2f7c1a9d04b3e.

sector "pagewell.example" ->
  oWRiC7zqAIj3qe4j0TWp8YDSo6xsNYKjg2ejuhKPOX8
sector "quillbox.example" ->
  0iHFYrZ2ItCcw_GHyPsY4jBIjLEPCjJyhc9LKRchaOg

Same person, same provider, same instant, two identifiers with no visible relationship. Note what has and has not happened. The two shops cannot join their databases on the identifier. The provider can, trivially, because it holds the salt and the local account id. And if both shops also received amara.d@pagewell-staff.example in an email claim, they can join on that in one line of SQL, and the pairwise identifier has bought nothing.

Which brings us to the single most valuable sentence in the specification for anybody building a login system. Core section 5.7, “Claim Stability and Uniqueness”, says that the sub and iss claims from the ID token, used together, are the only claims a relying party can rely upon as a stable identifier for the End-User, because sub must be locally unique and never reassigned within the issuer. It then says that all other claims carry no such guarantees across different issuers in terms of stability over time or uniqueness across users, that issuers are permitted to apply local restrictions and policies, and therefore that other claims such as email, phone_number, preferred_username and name MUST NOT be used as unique identifiers for the End-User, whether obtained from the ID token or from the UserInfo endpoint.

That rule is not theoretical, and the cost of breaking it has a name and a date. On 20 June 2023 Omer Cohen of Descope published “nOAuth”, describing a class of full account takeover against applications using “Log in with Microsoft”. In Microsoft Entra ID, then Azure AD, the email claim was both mutable and unverified: an attacker could create their own tenant, set the email attribute on an account they controlled to the victim’s address, sign in to a vulnerable application, and be merged into the victim’s account. No token was forged; every token was validly signed and correctly audienced. The only defect was that the relying party keyed its user records on email instead of on the pair of iss and sub. Microsoft responded with claims indicating whether an email domain is verified and by omitting unverified-domain claims for most applications. Research reported in June 2025 still found roughly nine per cent of the Entra multi-tenant applications tested to be vulnerable, two years after disclosure.

The rule, stated once more, because everything else in this section is commentary on it: the primary key for a federated user is the pair (iss, sub). An email address is a contact detail.

Session management, front-channel logout and back-channel logout#

OpenID Connect handles the end of a session in four separate documents, which tells you something about how hard it turned out to be.

RP-Initiated Logout 1.0 covers a relying party asking the provider to log the user out. The provider advertises end_session_endpoint, and the relying party sends the browser there with id_token_hint, which is RECOMMENDED and identifies the session, plus the OPTIONAL logout_hint, client_id, post_logout_redirect_uri, state and ui_locales.

Session Management 1.0 lets a relying party detect that the provider’s session has changed without asking. The provider publishes check_session_iframe and returns a session_state value with the authentication response: a JSON string representing the End-User’s login state at the provider, opaque to the relying party, containing no space, and REQUIRED if session management is supported. The relying party loads a hidden iframe from the provider and polls it by postMessage, sending the client id, a space, and the session state. The reply is exactly one of unchanged, changed or error. On changed the relying party must re-authenticate with prompt=none; on error it must not, to avoid infinite loops of traffic to the provider.

Front-Channel Logout 1.0 has the provider render the browser through each relying party’s logout page. The relying party registers frontchannel_logout_uri and optionally frontchannel_logout_session_required; the provider advertises frontchannel_logout_supported and frontchannel_logout_session_supported. The provider may add iss and sid query parameters so the relying party can validate the request and identify which of several sessions to end, and the specification says that if either is included, both must be.

Back-Channel Logout 1.0 has the provider call each relying party server directly. The relying party registers backchannel_logout_uri and optionally backchannel_logout_session_required; the provider advertises backchannel_logout_supported and backchannel_logout_session_supported. The provider then sends an HTTP POST with application/x-www-form-urlencoded body containing a logout_token parameter. The logout token is a JWT resembling an ID token, with these claims: iss, aud, iat, exp and jti all REQUIRED; sub and sid both OPTIONAL but at least one of the two mandatory; and events REQUIRED, a JSON object whose single member name is the back-channel logout event identifier shown in the example below, and whose value should be the empty object. A nonce claim is PROHIBITED, and the specification explains exactly why: its absence makes a logout token syntactically invalid if somebody tries to pass it off as an ID token in a forged authentication response. Explicit typing with a typ header of logout+jwt is recommended for new deployments, and the media type application/logout+jwt is registered for it.

{
 "iss": "https://server.example.com",
 "sub": "248289761001",
 "aud": "s6BhdRkqt3",
 "iat": 1471566154,
 "exp": 1471569754,
 "jti": "bWJq",
 "sid": "08a5019c-17e1-4977-8f42-65a12843ea02",
 "events": {
  "http://schemas.openid.net/event/backchannel-logout": {}
 }
}

Validation is an eleven-step list in Back-Channel Logout section 2.6, mirroring ID token validation with additions: verify iss, aud, iat and exp as for an ID token, verify that sub, sid or both are present, verify the events member, verify that no nonce claim is present, and optionally check the jti for replay and match iss, sub and sid against a session you have. An alg of none must not be used. Success is HTTP 200, failure HTTP 400, and the response should carry Cache-Control: no-store. Section 4 recommends expiry at most two minutes ahead, so captured tokens cannot be replayed.

The comparison, honestly stated:

Property Front-channel Back-channel
Transport Browser iframes Server to server POST
Needs RP reachable No Yes, from the OP
Blocked by cookie rules Yes No
Clears browser state Yes, directly No, RP must do it
Reliable No Reasonably

The cookie row is the decisive one as of August 2026, and both the Front-Channel Logout and Session Management specifications say so in their own text. They note that user agents have begun blocking third-party content by default, that a frontchannel_logout_uri rendered in an iframe may therefore be unable to reach the relying party’s login state because the iframe is in a different origin, and that cookie-based session management may return changed on every call and so loop for ever. Their recommendation is defensive code and telling the End-User that the logout could not be performed, with the frank admission that the details vary by browser and change over time.

The practical position: back-channel logout is the mechanism to build on if your relying parties are reachable servers, front-channel is the only option for those that are not, and neither relieves a relying party of writing the code that destroys its own session. Chapter 36 deals with what a session is and what destroying one means.

The complete exchange, annotated from redirect to validated ID token#

Now the whole thing, with Amara, Pagewell and Larkspur, on Monday 17 August 2026.

 Amara's       Pagewell             Larkspur
 browser       (RP)                 (OP)
    |            |                     |
    |  1 click   |                     |
    |----------->|                     |
    |            | 2 GET .well-known/  |
    |            |   openid-config     |
    |            |-------------------->|
    |            |<--------------------|
    | 3 302 to authorize endpoint      |
    |<-----------|                     |
    | 4 GET /authorize?...             |
    |--------------------------------->|
    |         5 login + consent        |
    |<-------------------------------->|
    | 6 302 back with code             |
    |<---------------------------------|
    |  7 GET /cb?code=..&state=..      |
    |----------->|                     |
    |            | 8 POST /token       |
    |            |-------------------->|
    |            |<-- id_token + at ---|
    |            | 9 GET /keys (cache) |
    |            |-------------------->|
    |            | 10 validate locally |
    |            | 11 GET /userinfo    |
    |            |-------------------->|
    |            |<--- claims + sub ---|
    | 12 session cookie, logged in     |
    |<-----------|                     |

Step 2 is discovery: Pagewell fetches the configuration document from the issuer it was configured with, and checks that the issuer member equals that issuer string. Steps 3 and 4 are the authentication request. Pagewell generates a state value and a nonce value, both from a cryptographically secure random source, stores them against the browser session, and redirects.

GET /authorize
  ?response_type=code
  &client_id=pagewell-web
  &redirect_uri=https%3A%2F%2Fpagewell.example%2Fcb
  &scope=openid%20email
  &state=af0ifjsldkj
  &nonce=n-0S6_WzA2Mj
  &code_challenge=E9Melhoa2Owv...
  &code_challenge_method=S256
Host: id.larkspur.example

response_type=code selects the authorization code flow, scope must contain openid or this is not an OpenID Connect request at all, state protects the redirect against cross-site request forgery, and code_challenge is PKCE, which chapter 40 covers.

Step 5 is entirely Larkspur’s business; Pagewell learns nothing about how it was done except through amr and acr afterwards. Steps 6 and 7 return the code, and Pagewell checks state against what it stored before doing anything else, discarding the stored value so it cannot be replayed. Step 8 exchanges the code at the token endpoint, over TLS, with client authentication and the PKCE verifier. The response, per Core section 3.1.3.3, carries the tokens.

{
 "access_token": "SlAV32hkKG_c8Xk1p9Qy7T",
 "token_type": "Bearer",
 "expires_in": 3600,
 "refresh_token": "8xLOxBtZp8",
 "scope": "openid email",
 "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImxrLTIw..."
}

The ID token is three base64url segments joined by dots. Its header and payload decode to this, and the at_hash value below is the genuine half-hash of the access token printed above.

{"alg":"RS256","kid":"lk-2026-08-a","typ":"JWT"}
{
 "iss": "https://id.larkspur.example",
 "sub": "2f1c9b0e7a4d8e63",
 "aud": "pagewell-web",
 "exp": 1786958700,
 "iat": 1786958400,
 "auth_time": 1786958340,
 "nonce": "n-0S6_WzA2Mj",
 "acr": "urn:mace:incommon:iap:silver",
 "amr": ["pwd", "otp"],
 "at_hash": "-E8VrhEsDxoU_o01lG2wEA",
 "sid": "08a5019c-17e1-4977-8f42-65a12843ea02"
}

The three timestamps are real Unix times: auth_time is 09:19:00 UTC on 17 August 2026, iat is 09:20:00, and exp is 09:25:00, giving the token a five-minute life. The base64url encoding of that payload begins eyJpc3MiOiJodHRwczovL2lkLmxhcmtzcHVyLmV4YW1w and runs to 400 characters.

Step 9 and 10 are validation, and this is the part to get right.

 1  kid = lk-2026-08-a -> known? yes, use it.
 2  verify RS256 signature over header.payload
 3  iss == "https://id.larkspur.example"   exact
 4  aud contains "pagewell-web"; no untrusted
    extra audiences present
 5  exp 1786958700 is in the future (leeway 60s)
 6  iat 1786958400 is recent enough
 7  nonce == the stored "n-0S6_WzA2Mj"; then
    delete the stored value
 8  at_hash == first 16 bytes of SHA-256 of the
    access token, base64url  -> matches
 9  acr is one we accept for this action
10  auth_time within our max_age policy
11  only now: read sub, and key the account on
    (iss, sub) = (id.larkspur.example,
                  2f1c9b0e7a4d8e63)

Step 11 is UserInfo, called with the access token, and its answer is checked so that its sub equals 2f1c9b0e7a4d8e63 before a single other field from it is used.

Step 12 is Pagewell’s own session. From here the ID token has done its job and should be discarded; what keeps Amara logged in is Pagewell’s cookie, with Pagewell’s lifetime and Pagewell’s revocation. Chapter 37 deals with the cookie.

Certification, profiles, and what conformance proves#

The protocol has a lineage worth knowing. OpenID 1.0 was created in May 2005 by Brad Fitzpatrick, then at Six Apart and the author of LiveJournal. OpenID Authentication 2.0 was ratified on 5 December 2007 and was a genuinely different protocol, based on identifiers that were URLs and on a discovery mechanism that asked users to type a web address. It was not widely loved. OpenID Connect, approved on 26 February 2014, kept the name and threw away the mechanism, rebuilding on OAuth 2.0 and JSON Web Tokens.

The OpenID Foundation introduced the OpenID Connect Certification programme at the RSA Conference on 22 April 2015. Google, Microsoft, ForgeRock, Ping Identity, Nomura Research Institute and PayPal were the first to certify. At the launch, six organizations had certified eight provider implementations across twenty-one conformance profiles. The published roadmap added relying-party certification and opened self-certification to members in good standing from May 2015, with general availability planned for January 2016.

Certification is self-certification: the implementer runs the Foundation’s conformance test suite against their own deployment, submits the results, pays a fee and may then use the certification mark. That sets the value of the mark precisely. It proves that a named version of a named product passed a named profile of tests on a named date. It does not prove that the deployment you are calling is configured the way the tested one was, nor that a relying party built against it is correct.

Date Event
May 2005 OpenID 1.0 created
5 Dec 2007 OpenID 2.0 ratified
26 Feb 2014 OpenID Connect final
22 Apr 2015 Certification launched
15 Dec 2023 Core errata set 2
2024 Nine specs become ISO/IEC
19 Feb 2025 FAPI 2.0 Security Profile final

Above the base protocol sit profiles that tighten it. The Financial-grade API family is the most important: the FAPI 2.0 Security Profile was approved as final on 19 February 2025, and it removes choices rather than adding features, mandating sender-constrained tokens, PKCE and exact redirect URI matching among others. Where OpenID Connect says MAY, a profile says MUST, and open banking regimes in several countries build on that.

The honest summary of the programme is that it did something no specification could do alone: it made interoperability testable and the profiles concrete. It is not a security audit. The failures catalogued in this chapter, nOAuth, the confused-deputy login pattern, and the Sign in with Apple flaw disclosed by Bhavuk Jain on 30 May 2020 for which Apple paid a 100,000 US dollar bounty, all occurred at implementations that were, in the ordinary sense, conformant.

41.98 Common wrong ideas#

Wrong: OAuth 2.0 is a login protocol, so a correct OAuth implementation gives me correct authentication. Right: OAuth 2.0 is a delegated authorization protocol and RFC 6749 says nothing about who the user is. Authentication comes from the OpenID Connect layer above it, whose ID token is addressed to one relying party by client identifier; without that layer you are inferring identity from a credential designed to be transferable.

Wrong: I can call the provider’s user endpoint with an access token and use the identifier it returns to log somebody in. Right: That question has an answer which does not depend on who is asking, so any holder of any valid access token for that user gets logged in as them. This is the confused-deputy pattern found in 89 of 149 OAuth-using Android apps in 2014; identity must come from an ID token whose aud claim names you.

Wrong: An ID token is a credential I can send to my own back-end APIs. Right: An ID token is a receipt for one authentication event, addressed to one relying party and consumed once, while an access token is the credential for API calls. Mixing them means your API accepts tokens minted for another purpose, which is why RFC 9068 gives access tokens the distinct at+jwt type header.

Wrong: If the signature verifies, the ID token is valid. Right: The signature proves only that the issuer produced those bytes. Core section 3.1.3.7 also requires an exact iss match, an aud check that rejects untrusted extra audiences, an exp check, a nonce comparison when one was sent, and the hash checks in the flows that mandate them; the reported failures have all been missing non-cryptographic checks.

Wrong: The email address in the token is verified by a large provider, so it is safe as the primary key for my user records. Right: Core section 5.7 states that sub and iss used together are the only claims that can be relied on as a stable identifier, and that email, phone_number, preferred_username and name must not be used as unique identifiers. The nOAuth disclosure of 20 June 2023 turned that paragraph into account takeover, and research in June 2025 still found about nine per cent of tested multi-tenant applications vulnerable.

Wrong: Pairwise subject identifiers make sign-in anonymous. Right: A pairwise sub stops two relying parties correlating a user on the identifier alone, and does nothing about correlation on an email address, on a name and birth date, or on a device fingerprint, and nothing at all about the provider, which sees every sign-in and holds the salt. It is a narrow defence against colluding relying parties.

Wrong: With PKCE in place the nonce is redundant, so I can leave it out of the code flow. Right: PKCE binds the authorization code to the client instance that requested it, while the nonce binds the ID token to the browser session that asked for it, which is why Core section 3.1.2.1 keeps it as a separate control. Always sending one, and comparing it, gives a uniform rule instead of a conditional one you will get wrong.

Wrong: To be safe I should fetch the provider’s key set on every token I validate. Right: Core section 10.1.1 describes the intended pattern: cache the key set, select by the kid in the JOSE header, and refetch when an unfamiliar kid appears. Fetching per token turns every request into an outbound call and hands anyone who can send you tokens a lever on your traffic, so refresh must be rate-limited and must fall back to the cached set on failure.

Wrong: Logging the user out at the identity provider logs them out of everything. Right: Logout in OpenID Connect is a notification protocol, not a revocation protocol. Front-channel logout depends on the browser loading a page from every relying party, which the specification itself admits is being blocked by third-party content rules, and back-channel logout only delivers a signed token to reachable servers; the relying party’s own session dies only in code the relying party wrote.

Wrong: My identity provider is OpenID Certified, so my integration is secure. Right: Certification is self-certification against a named profile, run by the implementer with the Foundation’s test suite, recording that a version of a product passed a set of tests on a date. It says nothing about how the deployment you are calling is configured, and nothing whatever about your own relying-party code, which is where the failures in this chapter happened.

41.99 Chapter summary in 20 lines#

  1. OpenID Connect is a thin identity layer over OAuth 2.0, approved as final on 26 February 2014 and governed today by Core 1.0 incorporating errata set 2, dated 15 December 2023.
  2. OAuth 2.0 alone cannot authenticate, because its access tokens are bearer credentials scoped to a resource and not bound to the relying party that receives them.
  3. The classic failure is a confused deputy: a site takes an access token, asks the provider whose it is, gets a true answer, and logs in whoever handed the token over.
  4. Norm Hardy named that pattern in “The Confused Deputy”, in the ACM SIGOPS Operating Systems Review, volume 22, issue 4, October 1988.
  5. A 2014 study of 600 top Android applications found 149 using OAuth, of which 89, or 59.7 per cent, were implemented incorrectly and were vulnerable.
  6. The fix is the ID token, a signed JWT defined in Core section 2 whose aud claim names the one relying party it was issued to.
  7. Its required claims are iss, sub, aud, exp and iat, with auth_time and nonce conditionally required and acr, amr and azp optional.
  8. A sub is locally unique within its issuer, never reassigned, and at most 255 ASCII characters, so the identifier you store is always the pair of iss and sub.
  9. Core section 3.1.3.7 gives thirteen ordered validation steps, and the ones most often skipped are the audience check, the nonce comparison and the expiry check.
  10. The at_hash claim is the base64url of the left-most half of the hash of the ASCII access token, using the ID token’s own hash algorithm.
  11. It is required whenever an ID token is issued from the authorization endpoint alongside an access token, and c_hash does the same job for the code.
  12. Discovery 1.0 defines the .well-known/openid-configuration document, with six required members including issuer, jwks_uri and subject_types_supported.
  13. Discovery section 4.3 requires the returned issuer to be identical to the URL used to fetch the document and to the iss claim in issued ID tokens.
  14. Keys live at jwks_uri as a JWK Set, and rotation is signalled by the kid header, with retired keys left published for a reasonable period.
  15. A verifier should cache the key set, refresh on an unknown kid, rate-limit that refresh, and never fetch a key named inside a token.
  16. The UserInfo endpoint returns claims for an access token, and Core section 5.3.2 requires its sub to match the ID token’s sub or the response be discarded.
  17. The scopes profile, email, address and phone request fixed bundles of standard claims, while the claims parameter requests individual claims.
  18. Subject identifiers are public or pairwise, a pairwise value being a deterministic irreversible function of a sector identifier, a local account id and a secret salt.
  19. Core section 5.7 forbids using email, phone_number, preferred_username or name as unique identifiers, and the nOAuth disclosure of 20 June 2023 showed the cost of ignoring it.
  20. Logout exists in four documents, RP-initiated, session management, front-channel and back-channel, and the browser-based ones are degraded by third-party content blocking.

Chapter sources: OpenID Connect Core 1.0 incorporating errata set 2, N. Sakimura, J. Bradley, M. Jones, B. de Medeiros and C. Mortimore, 15 December 2023, sections 2, 3.1.2.1, 3.1.3.3, 3.1.3.6, 3.1.3.7, 3.2.2.1, 3.3.2.11, 5.1, 5.3, 5.3.2, 5.4, 5.5, 5.7, 8, 8.1, 10.1.1, 10.2, 15.5.2 and 16.11; OpenID Connect Discovery 1.0 incorporating errata set 2, N. Sakimura, J. Bradley, M. Jones and E. Jay, 15 December 2023, sections 2, 3, 4.1 and 4.3; OpenID Connect Back-Channel Logout 1.0 incorporating errata set 1, M. Jones and J. Bradley, whose preceding final version is dated September 2022, sections 2.1 to 2.8, 4, 4.1 and 5.3.1 [UNVERIFIED: the publication date of Back-Channel Logout 1.0 errata set 1]; OpenID Connect Front-Channel Logout 1.0, M. Jones, September 2022; OpenID Connect Session Management 1.0, B. de Medeiros, N. Agarwal, N. Sakimura, J. Bradley and M. Jones, September 2022; OpenID Connect RP-Initiated Logout 1.0, M. Jones, B. de Medeiros, N. Agarwal, N. Sakimura and J. Bradley, September 2022; RFC 6749, “The OAuth 2.0 Authorization Framework”, D. Hardt, October 2012; RFC 6750, “Bearer Token Usage”, October 2012; RFC 7517, “JSON Web Key”, M. Jones, May 2015; RFC 7519, “JSON Web Token”, May 2015; RFC 7234 for HTTP caching, June 2014; RFC 9068, “JWT Profile for OAuth 2.0 Access Tokens”, V. Bertocci, October 2021; RFC 9207, “OAuth 2.0 Authorization Server Issuer Identification”, K. Meyer zu Selhausen and D. Fett, March 2022; Norm Hardy, “The Confused Deputy: (or why capabilities might have been invented)”, ACM SIGOPS Operating Systems Review, volume 22, issue 4, October 1988; Rui Wang, Shuo Chen and XiaoFeng Wang, “Signing Me onto Your Accounts through Facebook and Google”, 2012 IEEE Symposium on Security and Privacy, pages 365 to 379; Eric Chen, Yutong Pei, Shuo Chen, Yuan Tian, Robert Kotcher and Patrick Tague, “OAuth Demystified for Mobile Application Developers”, ACM CCS, November 2014, for the 59.7 per cent figure across 149 OAuth-using apps of 600 examined; Omer Cohen, “nOAuth”, Descope, 20 June 2023, and the follow-up nOAuth research reported in June 2025 for the nine per cent residual figure; Bhavuk Jain, “Zero-day in Sign in with Apple”, 30 May 2020, for the 100,000 US dollar bounty and the issued-for-any-email flaw; the OpenID Foundation press release “The OpenID Foundation Launches OpenID Connect Certification Program”, RSA Conference, 22 April 2015; the OpenID Foundation announcement of nine OpenID Connect specifications published as ISO/IEC 26131:2024 through ISO/IEC 26139:2024, October 2024; the FAPI 2.0 Security Profile, approved final on 19 February 2025; and the live discovery documents of accounts.google.com, login.microsoftonline.com and appleid.apple.com together with Google’s JWK Set, all fetched on 17 August 2026.