From 317daf623df2649e6e79383dc0270d6c38e682f7 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 17 Aug 2026 13:40:38 +0530 Subject: [PATCH] docs(mfa): the MFA session is not browser-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page said 'the frontend authenticates the follow-up call ... via a short-lived MFA session cookie'. Read as browser-only, which is how two SDKs independently concluded they had to emulate a user agent — one of them shipping a cookie-injection hole on the way. It is an ordinary Set-Cookie header carrying an opaque handle, and the server accepts it back in an ordinary Cookie header. Any HTTP client can complete the flow by reading one value and sending it back; gRPC carries the same handle as metadata. Adds the two-command curl recipe, the gRPC equivalent, and the one invariant a cookie jar would have provided for free: attach it only to your configured Authorizer base URL and never across a redirect, because skip_mfa_setup exchanges the handle for a full access token. Also states why a general-purpose jar is the wrong tool here — Secure on http://localhost and per-language domain-matching rules are exactly the bugs both SDKs hit. --- docs/core/graphql-api.md | 2 ++ docs/core/security.md | 56 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/docs/core/graphql-api.md b/docs/core/graphql-api.md index f0fcd0e..512bce6 100644 --- a/docs/core/graphql-api.md +++ b/docs/core/graphql-api.md @@ -819,6 +819,8 @@ Completes an in-progress, token-withheld MFA first-time-setup offer by recording Either `email` or `phone_number` is required. Returns `AuthResponse` (same shape as [`verify_otp`](#verify_otp)). Fails with an error when MFA is org-enforced (`--enforce-mfa`) — enforcement is never skippable. +This call is authenticated by the MFA session, not a bearer token — none has been issued yet. The session arrives in a `Set-Cookie` header on the `signup`/`login` response and goes back in a `Cookie` header here, so **any HTTP client can complete the flow**, not only a browser. See [Completing the flow from a non-browser client](../core/security#completing-the-flow-from-a-non-browser-client). + ```graphql mutation { skip_mfa_setup(params: { email: "foo@bar.com" }) { diff --git a/docs/core/security.md b/docs/core/security.md index 0d5ead4..c44b894 100644 --- a/docs/core/security.md +++ b/docs/core/security.md @@ -485,11 +485,57 @@ In every withheld case the response carries no `access_token` — only a message and a set of `should_show_*` / `should_offer_*` flags on `AuthResponse` (`should_show_totp_screen`, `should_offer_webauthn_mfa_setup`, `should_offer_email_otp_mfa_setup`, `should_offer_sms_otp_mfa_setup`, -`should_offer_webauthn_mfa_verify`). The frontend authenticates the -follow-up call (`verify_otp`, `totp_mfa_setup`, `webauthn_registration_verify`, -`webauthn_login_verify`, or `skip_mfa_setup`) via a short-lived MFA session -cookie set alongside that response, not a bearer token — none has been -issued yet. `should_offer_mfa_setup` is deprecated and never set; ignore it. +`should_offer_webauthn_mfa_verify`). The follow-up call (`verify_otp`, +`totp_mfa_setup`, `webauthn_registration_verify`, `webauthn_login_verify`, or +`skip_mfa_setup`) is authenticated by a short-lived **MFA session** set alongside +that response, not by a bearer token — none has been issued yet. +`should_offer_mfa_setup` is deprecated and never set; ignore it. + +#### Completing the flow from a non-browser client + +The MFA session travels as a cookie, but it is **not browser-only**. It is an +ordinary `Set-Cookie` response header carrying an opaque handle, and the server +accepts it back in an ordinary `Cookie` request header — so any HTTP client can +complete the flow. A browser does it automatically; everything else reads one +value and sends it back. + +```bash +# 1. Sign up. The token is withheld; the handle arrives in Set-Cookie. +curl -i -X POST "$AUTHORIZER_URL/graphql" \ + -H 'Content-Type: application/json' -H "Origin: $AUTHORIZER_URL" \ + -d '{"query":"mutation { signup(params: {email: \"a@b.com\", password: \"Password@123\", confirm_password: \"Password@123\"}) { access_token message } }"}' + +# HTTP/1.1 200 OK +# Set-Cookie: mfa_session=e415fa93-f51e-4ee1-8568-bd7cf2e0fa67; ... +# {"data":{"signup":{"access_token":null,"message":"Proceed to mfa setup"}}} + +# 2. Echo it back. No cookie jar involved. +curl -X POST "$AUTHORIZER_URL/graphql" \ + -H 'Content-Type: application/json' -H "Origin: $AUTHORIZER_URL" \ + -H 'Cookie: mfa_session=e415fa93-f51e-4ee1-8568-bd7cf2e0fa67' \ + -d '{"query":"mutation { skip_mfa_setup(params: {email: \"a@b.com\"}) { access_token } }"}' +# -> access_token issued +``` + +Over **gRPC** the same handle is carried as metadata: read it from the +`set-cookie` response metadata, send it back as a `cookie` entry. + +:::caution Send it only to your own Authorizer +A browser's cookie jar scopes cookies to the origin that set them. If you carry +the handle by hand you take on that job: **attach it only to requests to your +configured Authorizer base URL, and never follow a redirect while it is +attached.** The handle authenticates a half-completed login — `skip_mfa_setup` +exchanges it for a full access token — so treat it exactly like a credential: +hold it in memory for the duration of the flow, never log it, never persist it. +::: + +You do not need a general-purpose cookie jar for this, and reaching for one has +been a reliable source of bugs: `Secure` cookies are dropped over +`http://localhost` by some HTTP stacks, and domain-matching rules vary between +languages. Reading `mfa_session` by name and echoing it back to one known origin +has none of those failure modes. The official +[Go](https://github.com/authorizerdev/authorizer-go) and +[Python](https://github.com/authorizerdev/authorizer-py) SDKs do this for you. A registered passkey satisfies MFA on its own — no OTP/TOTP re-challenge — because every WebAuthn assertion already requires user verification