You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
MFA session should be resumable without a cookie (backend SDKs) #784
Recovering it requires skip_mfa_setup or verify_otp, and both read the session from a cookie only — cookie.GetMfaSession(gc) at verify_otp.go:58 and skip_mfa_setup.go:28. There is no header or body alternative; SkipMfaSetupRequest.state is for resuming the OAuth code flow, not for carrying the session.
A cookie is a browser mechanism. Both backend SDKs ship signup, login, verify_otp and skip_mfa_setup, so today those methods cannot complete without an HTTP cookie jar — which is what both PRs add, and which is a workaround for a browser-shaped API rather than a fix.
What the rest of the industry does
Neither major competitor uses a cookie for this on the API surface:
stateToken / stateHandle in the response body, sent in the request body to verify
Both are opaque, short-lived, single-use handles — the same thing our mfa_session already is, just carried where an API client can reach it.
Proposal
Return the MFA session handle in the auth response alongside should_show_totp_screen, and accept it as an optional field on VerifyOTPRequest / SkipMfaSetupRequest / the *_mfa_setup inputs.
Cookie stays the default — the login UI is unaffected, and nothing about the browser flow changes.
Body parameter is additive — the server prefers the cookie when present, so this is not a breaking change.
Same store, same TTL, same single-use semantics; only the transport differs.
The SDKs could then drop the cookie-jar workaround entirely.
Why it is worth doing beyond tidiness
The Python jar needed a deliberate loopback shim, because http.cookiejar predates Secure Contexts and drops a Secure cookie on http://localhost. Getting that shim right took two attempts and one self-inflicted cookie-injection hole (a remote origin could plant Domain=localhost). Every future SDK — PHP, Java, Rust — inherits that same trap. A body parameter has none of it.
Closing against the finding rather than implementing it: the mechanism already exists.
What I verified
The handle is already on the wire, and the server already accepts it from a plain header. End to end against a default-config server:
# 1. signup withholds the token AND publishes the handle
{"data":{"signup":{"access_token":null,"message":"Proceed to mfa setup"}}}
Set-Cookie: mfa_session=e415fa93-f51e-4ee1-8568-bd7cf2e0fa67
# 2. complete it with a MANUAL Cookie header — no jar, no policy, no new field
access_token issued: True
message : MFA setup skipped
gRPC is the same: cookiesFromMetadata (grpc_metadata.go:158) reads a cookie metadata key, and outbound cookies are serialised to set-cookie metadata.
Why this is better than the body field I proposed
I planned return_mfa_session plus mfa_session across eleven inputs and a response type. That would have been strictly worse:
Server change
New API
Browser exposure
SDK complexity
Header echo (exists today)
none
none
unchanged
low
Body field + opt-in flag
yes
2 fields × 11 inputs
new, needs managing
low
A body field puts the handle somewhere it has never been, then needs a forgeable opt-in flag to keep it off the browser path. The header echo puts it nowhere new — Set-Cookie is already sent to every client on these calls. No new exposure means no flag to get wrong, and the server-side threat model is literally unchanged.
To be explicit, since it can look like one: this is not an HttpOnly bypass. HttpOnly constrains browser script, not HTTP clients. Page script still cannot read the cookie.
The actual bug
Nobody documented that the handle was readable. The docs said "the frontend authenticates the follow-up call…", so two SDKs independently built user-agent emulation for a two-line problem — and the Python one took two attempts and produced a cookie-injection hole along the way.
Fixed:
docs(mfa): the MFA session is not browser-only docs#93 — states the handle is not browser-only, gives the two-command recipe and the gRPC equivalent, and writes down the one invariant a jar gave for free: attach it only to your configured base URL, never across a redirect.
Go's stdlib jar is already correct (it implements the Secure Contexts loopback exception), so it needs no change.
What would justify reopening
If the handle ever needs to go somewhere a header cannot — a redirect-based flow carrying it in a query parameter, or a non-HTTP transport. Neither exists today. Vendor parity alone (Cognito's Session, Auth0's mfa_token, Okta's stateToken) is not sufficient reason for new API surface when the existing transport already works for every client.
Activity
lakhansamani commentedon Aug 17, 2026
Closing against the finding rather than implementing it: the mechanism already exists.
What I verified
The handle is already on the wire, and the server already accepts it from a plain header. End to end against a default-config server:
gRPC is the same:
cookiesFromMetadata(grpc_metadata.go:158) reads acookiemetadata key, and outbound cookies are serialised toset-cookiemetadata.Why this is better than the body field I proposed
I planned
return_mfa_sessionplusmfa_sessionacross eleven inputs and a response type. That would have been strictly worse:A body field puts the handle somewhere it has never been, then needs a forgeable opt-in flag to keep it off the browser path. The header echo puts it nowhere new —
Set-Cookieis already sent to every client on these calls. No new exposure means no flag to get wrong, and the server-side threat model is literally unchanged.To be explicit, since it can look like one: this is not an HttpOnly bypass. HttpOnly constrains browser script, not HTTP clients. Page script still cannot read the cookie.
The actual bug
Nobody documented that the handle was readable. The docs said "the frontend authenticates the follow-up call…", so two SDKs independently built user-agent emulation for a two-line problem — and the Python one took two attempts and produced a cookie-injection hole along the way.
Fixed:
SecureandMax-Age=0still honoured; origin scoping is now structural. Verified on 3.9–3.13 plus a live MFA flow and the 56-test integration suite.What would justify reopening
If the handle ever needs to go somewhere a header cannot — a redirect-based flow carrying it in a query parameter, or a non-HTTP transport. Neither exists today. Vendor parity alone (Cognito's
Session, Auth0'smfa_token, Okta'sstateToken) is not sufficient reason for new API surface when the existing transport already works for every client.