Skip to content

Handoff: BFF step-up cannot complete under response_mode=form_post

Use this prompt in the Authifi BFF repository. Observed against ghcr.io/authifi/idbroker-tools/bff-gateway:3.1.0 while integrating Floh LSA-9825. The RFC 9470 step-up feature added in 3.1.0 fails every time under the image's own default response mode.


Summary

POST /bff/callback rejects every step-up completion with invalid_reauthorization_state. The user completes MFA successfully; the gateway then discards the result.

The reauthorization transaction is stored in the session cookie, which is SameSite=Lax. The callback is a cross-site POST (response_mode=form_post), and browsers do not send Lax cookies on cross-site POST. The transaction is therefore never readable at the callback.

This is not environment-specific. With form_post — the library's default for this configuration — step-up can never succeed.

Evidence

utils/reauthorization.js stores and reads the transaction on the session:

function createReauthorizationTransaction(req, sessionName, parsed, stepUp) {
  // ...
  const session = req[sessionName];
  if (session) {
    session[exports.STEP_UP_NAMESPACE] = transaction; // <- session cookie
  }
  req[exports.TRANSACTION_SYMBOL] = id;
  return id;
}

function consumeReauthorizationTransaction(req, sessionName, expectedId, now, ttlSeconds) {
  const session = req[sessionName];
  if (!session) {
    return { status: "absent" }; // <- always, on form_post
  }
  // ...
}

middlewares/bff-oidc.middleware.js turns absent into the observed error:

const result = consumeReauthorizationTransaction(
  req,
  sessionName,
  transactionId,
  now,
  stepUp.stateTtlSeconds,
);
if (result.status === "absent" || result.status === "mismatched") {
  throw new Error("invalid_reauthorization_state");
}

The library already knows form_post needs a relaxed SameSite, and applies it to its own transaction cookie only (@axleresearch/express-openid-connect@2.18.4, lib/context.js:400):

sameSite:
  options.authorizationParams.response_mode === 'form_post'
    ? 'None'
    : config.transactionCookie.sameSite,

The session cookie gets no equivalent escalation. lib/config.js:75 defaults session.cookie.sameSite to 'Lax', and the image's production.json sets only httpOnly and secure, so Lax is the shipped default.

Observed directly on GET /bff/login?popup=true&acr_values=mod-mf&max_age=300:

Cookie SameSite under form_post under query
auth_verification (library transaction state) None Lax
<session> (holds the step-up transaction) Lax Lax

The contrast in the first column is the defect in one line: the only cookie escalated for the cross-site POST is the one the step-up feature does not use.

Consequences beyond the visible error

  • The token exchange succeeds before the state check fails, so the authorization code is consumed and an access token is minted and discarded. Each retry burns a full round trip and, for a phishing-resistant authenticator, a real user gesture.
  • Because the session cookie never arrives, the callback is processed as an unauthenticated request. Confirm that a failed step-up cannot displace or regenerate the caller's existing session.
  • The failure is indistinguishable, from the browser, from "your MFA was rejected". Users read a successful passkey ceremony followed by "verification failed".

Required change

Do not store callback-critical state in the session cookie. Options, in descending preference:

  1. Carry the transaction in the login state. The transaction id already round-trips through getLoginState → OAuth state, and the library mirrors state into the auth_verification cookie, which is escalated to SameSite=None for form_post and is integrity-checked at the callback. Moving the transaction body (mode, popupSuccessPath, popupFailurePath, returnTo, acrValues, maxAge, createdAt) alongside the id removes the session dependency entirely. Note the fields are not secret, but state is plaintext base64 in the URL — if any future field is sensitive, encrypt rather than reverting to the session.
  2. Use a dedicated transaction cookie that applies the same form_post ⇒ None rule the library applies to auth_verification.

Do not fix this by escalating the session cookie to SameSite=None. That sends the session on every cross-site request and removes a defense-in-depth layer from all consumers to serve one feature.

Interim workaround Floh is using

Setting auth.authorizationParams.response_mode = "query" makes the callback a top-level GET, which Lax cookies do accompany, so the transaction is found. The library registers the callback for GET (middleware/auth.js:62) and PKCE (S256) is already in use, so the authorization code is single-use and verifier-bound.

We consider this a workaround, not a resolution: the gateway's access log records full query strings, so query mode writes authorization codes into logs. That is a poor trade to have to make, and it is why we would rather have the state moved than the response mode changed.

Tests required

  1. Full popup step-up round trip under response_mode=form_post completes and returns a replacement token. This is the regression test — it fails on 3.1.0 today.
  2. Same under response_mode=query.
  3. Session cookie remains SameSite=Lax in both.
  4. A failed or expired step-up leaves the caller's pre-existing session intact.
  5. A tampered transaction payload is rejected (covers option 1, where the payload leaves the server).

Acceptance handoff

Return the release version and immutable image digest, links to the tests above, and confirmation of which storage option was chosen. Floh pins exact versions and will re-run its portal step-up pass against the new image.

Stop conditions

Stop and report BLOCKED rather than improvising if:

  • the transaction payload cannot be integrity-checked once it leaves the server, and no dedicated-cookie option is workable;
  • fixing this requires the session cookie to become SameSite=None.