Rebounder Tech Blog

Written by the people who actually run these systems in production.

session cookie Stays Stale After Firebase TOTP Enroll

Published About 7 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

A successful multiFactor(user).enroll() call never touches the session cookie already in the browser, so the server's second-factor check keeps failing right after enrollment.

Conclusion

Completing a TOTP enrollment with Firebase Authentication’s multiFactor(user).enroll() never updates the browser’s session cookie. That cookie was issued earlier, from an ID token that never passed a second factor, so a server-side check that gates a role on “did this session clear a second factor” keeps answering “not yet” immediately after enrollment succeeds. The fix is to call user.getIdTokenResult(true) right after enrollment to force a refresh, check the returned signInSecondFactor claim, and if it’s still missing, send the user through a normal re-login instead of letting them proceed.

The failure mode

Consider Identity Platform (Firebase Authentication) with TOTP (an authenticator-app second factor) made mandatory for a specific role after the fact. Affected users had been logging in with just a password, so they first enroll TOTP from an already-logged-in state — something like an “account/mfa” screen.

The enrollment flow itself succeeds at the IdP level. multiFactor(user).enroll(assertion, "Authenticator app") resolves, and re-reading multiFactor(user).enrolledFactors shows the new factor. But immediately after enrolling, trying to reach a protected page or action gets blocked again with “second factor not satisfied.” The browser isn’t bounced back to a login screen either — from the user’s point of view, enrollment appeared to work and then nothing changed.

Cause

The single source of truth for the enforcement check is the claim embedded in the ID token carried by the session cookie. That’s by the IdP’s own design: the firebase.sign_in_second_factor claim is only attached to an ID token issued after second-factor verification during login (MultiFactorResolver.resolveSignIn).

The MfaEnrollment component’s enrollment handler implements this literally:

// at sha 99ffbf95, apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
try {
  const assertion = TotpMultiFactorGenerator.assertionForEnrollment(secret, code.trim());
  await multiFactor(user).enroll(assertion, "Authenticator アプリ");
} catch {
  setError(
    "コードが正しくないか、登録に失敗しました。authenticator のコードを確認してください。",
  );
  return;
}

Here, user was pulled from the client SDK’s currentUser before the enroll call, and its ID token is still a cached reference to the password-only login. enroll() itself is an API call that adds a second factor on the IdP side — it has no effect on the ID token the browser is holding, or on any session cookie already issued from it.

In other words, “enrollment state on the IdP” and “token/cookie state in the browser” are now two separate things. Given that the server’s per-role enforcement check reads only the cookie’s claim by design (ADR-031’s addendum states explicitly that the single source of truth for enforcement is the IdP’s firebase.sign_in_second_factor claim), enrollment succeeding changes nothing until the cookie is reissued.

The fix

Right after enrollment succeeds, insert a step that explicitly reissues the session cookie using an ID token that has actually passed the second factor:

// at sha 99ffbf95, apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
async function refreshSessionWithSecondFactor(user: User): Promise<boolean> {
  try {
    const result = await user.getIdTokenResult(true);
    if (!result.signInSecondFactor) {
      return false;
    }
    const res = await fetch("/api/auth/session", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ idToken: result.token }),
    });
    return res.ok;
  } catch {
    return false;
  }
}

The key is the true argument to getIdTokenResult(true): it bypasses the SDK’s cache and forces a round trip to the IdP for a fresh ID token. Called right after enrollment, the new token carries the signInSecondFactor claim. Handing that token to the server’s /api/auth/session lets the session cookie be swapped for one that reflects a second factor having been passed.

The handler that fires when enrollment is confirmed branches on this function’s return value:

// at sha 99ffbf95, apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
// If enforcement is pending (MFA not yet satisfied), reissue the cookie with a
// second-factor token so the user can proceed immediately. If that fails,
// fall back to a re-login that includes the auth code (button below).
if (mfaPending && (await refreshSessionWithSecondFactor(user))) {
  window.location.assign("/");
  return;
}
router.refresh();

There’s a real edge case worth flagging: refreshSessionWithSecondFactor can return false — say, the signInSecondFactor claim unexpectedly doesn’t appear, or /api/auth/session fails due to a network error. Silently falling through to just router.refresh() here would put the user right back in the original symptom: “enrolled but can’t get in.” The implementation doesn’t give up at that point — it surfaces dedicated guidance for the reissue-failed case:

// at sha 99ffbf95, apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
{mfaPending && enrolled.length > 0 ? (
  <section style={warningCardStyle}>
    <p style={warningTextStyle}>
      登録済みです。続けて使うには、一度ログアウトして認証コード付きでログインし直してください。
    </p>
    <button type="button" onClick={signOutForMfaLogin} style={primaryBtnStyle}>
      ログインし直す
    </button>
  </section>
) : null}

signOutForMfaLogin tears down both the client and server sessions and sends the user to login:

// at sha 99ffbf95, apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
async function signOutForMfaLogin(): Promise<void> {
  await signOut(getClientAuth()).catch(() => undefined);
  await fetch("/api/auth/signout", { method: "POST" }).catch(() => undefined);
  window.location.assign("/login?next=/");
}

On re-login, the normal password submission throws auth/multi-factor-auth-required, which is what switches the UI to a TOTP code prompt — and only that path, via MultiFactorResolver.resolveSignIn, yields an ID token that actually went through second-factor verification.

// at sha 99ffbf95, apps/web/app/login/_components/LoginForm.tsx
const credential = await signInWithEmailAndPassword(getClientAuth(), email, staffPassword);
await establishSession(await credential.user.getIdToken());
} catch (e) {
  if ((e as { code?: unknown }).code === "auth/multi-factor-auth-required") {
    // Password is correct. Switch to second-factor input (ADR-031).
    setMfaResolver(getMultiFactorResolver(getClientAuth(), e as MultiFactorError));

The two-step design — “try to reissue the cookie in place right after enrollment” and “if that fails, fall back cleanly to a normal re-login” — closes off the case where a session cookie would otherwise sit permanently short of the second factor.

Preventing recurrence

The ADR-031 addendum also documents why the enforcement check’s basis was changed from “IdP enrollment count” to “session cookie claim”:

The decision is based on the session, not the enrollment count: the old gate (checking IdP enrollment count in the layout) is removed. AuthUser.mfaVerified is now set from the session cookie’s claim and enforced at every entry point — requireRole (page / Server Action), withSession (403), and getCurrentUser (Route Handler, 401). The layout-only check had a gap where Server Actions and Route Handlers could bypass it entirely (2026-10 audit).

That design change is a separate fix — closing a gap in enforcement coverage — but concentrating the decision into a single cookie/claim source is also what made “IdP enrollment vs. cookie state can disagree” surface directly as a user-visible symptom. The reissue-on-enrollment step was added as part of the same change, as the counterpart to that redesign.

The diff for this commit doesn’t include a regression test that exercises the reissue logic itself. The commit message notes that “TOTP isn’t supported by the Auth emulator, so this was verified with unit tests” — TOTP-related verification stays within what unit tests can do under the Auth emulator’s constraints. There’s no automated test in this diff that integrates the branch depending on the claim value returned by getIdTokenResult(true) (i.e., whether refreshSessionWithSecondFactor succeeds or fails). Anyone extending this same “reissue the cookie right after enrollment” pattern to another enforced role should plan a manual verification step up front, on the assumption that this branch still has no automated coverage.

Frequently asked questions

Q1Why can't a user get past the second-factor gate right after enrolling TOTP?

multiFactor(user).enroll() only registers the second factor with the IdP — it never updates the session cookie in the browser. That cookie's ID token never passed a second factor, so the server's check keeps reading 'not satisfied.'

Q2Why doesn't just reading the client SDK's currentUser fix it?

currentUser's ID token is cached, and right after enrollment it's still the old token with no signInSecondFactor claim. The claim only appears once user.getIdTokenResult(true) force-refreshes and fetches a new token from the IdP.

Q3What if the claim is still missing after the force refresh?

Sign the user out of both client and server sessions, then route them to a normal login requiring a TOTP code. signInWithEmailAndPassword then throws auth/multi-factor-auth-required, the only path yielding a token that actually passed second-factor verification.

Q4Under what conditions does this show up?

When TOTP becomes mandatory for a role after the fact and an already-logged-in user enrolls in that same session. A brand-new session requiring TOTP from login never hits this, since login itself goes through auth/multi-factor-auth-required first.

Environment verified

  • firebase (client SDK) ^12.14.0 / Next.js ^16.3.7 / React ^19.0.0
  • Implemented alongside a 2026-10-01 ADR addendum (system_admin phased enforcement)

What this article is based on

  • TypeScript file lines 56-141commit 99ffbf9
  • TypeScript file lines 49-77commit 99ffbf9
  • TypeScript file lines 159-168commit 99ffbf9
  • TypeScript file lines 228-239commit 99ffbf9
  • Markdown file lines 52-57commit ad3a6b6

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.