← Back to blog

We Wrote an RFC 9700 Matrix and It Found a Bug

· Brandon Corbett · 1 views
securityoauthseamless

One item on the v0.19 list was meant to be paperwork: publish a conformance matrix for RFC 9700, the OAuth 2.0 Security Best Current Practice, covering Seamless Auth's role as an OAuth client. Every row gets a status and a link to the code at a release tag.

It stopped being paperwork at the second row.

The bug

When you sign in with Google, GitHub, or any configured provider, the flow goes like this. The app asks the auth API to start, gets back a state and the provider's authorization URL, and sends the browser off. The provider sends the browser back with a code and that same state. The app posts both to the callback, and the API exchanges the code and signs the user in.

Our state was HMAC signed, expired after ten minutes, and was refused if used twice. PKCE was on. On paper, that is a well-defended flow.

What it was not was bound to the browser that started it. Nothing in the callback proved that the browser finishing the sign-in was the one that began it. The PKCE verifier did not help either, because it was derived on the server from the state itself, so anyone holding the state had everything PKCE would check.

That allows login CSRF, which RFC 9700 covers in section 4.7:

  1. An attacker starts a sign-in on your app and signs in at the provider as themselves.
  2. They stop before the callback, holding a valid state and code.
  3. They get your browser to load the callback URL with those values.
  4. Your browser completes the sign-in, into the attacker's account.

Being signed into someone else's account sounds harmless until you add a payment method, upload a document, or connect an integration, all of which now belong to the attacker.

In practice our React SDK blunted this: its callback screen only proceeded when the tab had a pending sign-in stored, which a link opened in a new tab does not. But that was a side effect, not a binding, and a native app or a custom integration calling the API directly had no check at all.

The fix

The fix shipped in auth API 0.19.2 as #463:

  • POST /oauth/:providerId/start now also returns a random binding. Only its SHA-256 goes into the signed state, and the binding never goes to the provider or into a URL.
  • POST /oauth/:providerId/callback must send the binding back. The API compares it in constant time, before recording the state as used, so a callback with the wrong binding cannot burn a state that its rightful browser has yet to finish.
  • A missing or wrong binding is refused with 400 Invalid OAuth state, and the auth event records which one it was, so you can see old clients during an upgrade.

The attacker in the scenario above has a binding, but only for their own browser. Your browser never had it, so the callback fails.

On the client side, @seamless-auth/client 0.7.1 keeps the binding in sessionStorage next to the state it belongs to and sends it with the matching callback, so apps using the SDK's sign-in actions get the fix by upgrading. On React Native it stays in memory, since the in-app browser hands the callback straight back to the same process. Server adapters pass it through unchanged.

Shipping a breaking security fix in a patch

The fix refuses callbacks from clients that do not send a binding. That is a break, and under our own 0.x rules a break usually means a minor version. We shipped it as a patch anyway, because the alternative was leaving the hole open until v0.20.

To make that survivable, OAUTH_ALLOW_UNBOUND_STATE=true lets an operator accept callbacks without a binding while their clients catch up. It reopens the hole while it is set, logs a warning at every boot saying so, and is removed in v0.20.

The upgrade also caught us out once. The admin console we bundle with the API called the raw client instead of the SDK's sign-in actions, so it did not send the binding, and OAuth sign-in to the console failed on 0.19.2. Dashboard 0.10.1 fixed it, and API 0.19.3 ships that dashboard. If your own app calls the raw client for OAuth, the OAuth guide shows what to change.

The matrix is the point

The full RFC 9700 matrix is published. The CSRF row now reads "Meets, configurable, since 0.19.2", with the escape hatch named. Other rows are honest about what is not a clean pass:

  • Mix-up attacks are only partly covered: we do not check the iss response parameter, and providers share a default redirect URI unless you give each its own.
  • Client authentication is client_secret_post only, not private_key_jwt or mTLS.
  • Sender-constrained tokens are not used.
  • TLS to providers is not enforced by the config schema, so https is on you.
  • The state replay cache is per instance, so with several replicas a replayed callback could be accepted by a different instance inside the ten-minute window. That is tracked for v0.21.

None of those is new. All of them were true before the matrix existed. The difference is that now you can read them before you deploy, instead of discovering them after.

A security checklist you keep in your head always passes. Writing one down, with a link to the code for every row, is how this bug went from "fine on paper" to fixed.

© 2026 Seamless Auth