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:
- An attacker starts a sign-in on your app and signs in at the provider as themselves.
- They stop before the callback, holding a valid
stateandcode. - They get your browser to load the callback URL with those values.
- 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/startnow also returns a randombinding. Only its SHA-256 goes into the signed state, and the binding never goes to the provider or into a URL.POST /oauth/:providerId/callbackmust 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
issresponse parameter, and providers share a default redirect URI unless you give each its own. - Client authentication is
client_secret_postonly, notprivate_key_jwtor mTLS. - Sender-constrained tokens are not used.
- TLS to providers is not enforced by the config schema, so
httpsis 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.