Change evidence report

Cross-device magic-link confirmation

PR 1910 closes a customer login CSRF and session-fixation path. A magic link now carries a public initiation selector while the requesting browser retains an independent 128-bit nonce. The API stores only the nonce hash. A matching browser can redeem silently; any other browser receives masked account context and must explicitly continue before the single-use token is claimed. Session-authority guards prevent a delayed confirmation from replacing a valid session adopted from another tab.

Range
54b6b4219 (merge-base with main)…b046e9fde (captured implementation head)
Source state
fix/1902-magic-link-confirmation at b046e9fde, pushed as PR #1910; this report is the following evidence-only commit
Generated

Reviewer brief

Outcome and scope

Forwarded links require a deliberate account adoption

The browser that requested a link proves continuity with a nonce that never enters the emailed URL. A forwarded or legacy link remains usable, but only after the visitor sees masked identity context and activates Continue. The bearer is stripped from the fragment before the request and is held in a React ref rather than rendered state. Existing valid sessions and later cross-tab session adoption remain authoritative.

What this report proves

The screenshot uses the production widget bundle built from implementation head 25306e986, whose widget source is unchanged at b046e9fde, in a desktop viewport with a mocked 409 preview response. Browser inspection observed the bearer removed from the URL and absent from both light DOM and shadow DOM. Focused API, database, widget, migration and real Chromium checks cover the security state transitions. The report does not prove production deployment order or replace CI and pull request review.

Claim map

Evidence coverage

Each material changed behavior or state maps to direct evidence or an explicit gap.

Behavior or claimEvidenceStatus
A link without matching browser initiation state does not silently create a sessionAPI 409 enforcement, confirmation screenshot, widget unit tests and real Chromium forwarded-link testCovered
The requesting browser can redeem silently with its stored nonceDatabase-backed nonce-hash assertion plus real Chromium same-browser testCovered
The visitor sees enough non-sensitive identity context to recognize the accountMasked-email API payload and rendered confirmation screenshotCovered
Existing and newly adopted valid sessions take precedence over an inbound linkGeneration and authority guards plus deferred-response tests for both race orderingsCovered
Tenant binding, expiry and single-use redemption remain enforcedBrand-scoped candidate query and atomic claim excerpt, API unit coverage and live PostgreSQL replay rejectionCovered
The browser regression test executes through the default repository CI commandwidgets-test package script and browser runner, exercised locally through bun run testCovered

Observed change

Review evidence

Evidence is grouped by the reviewer question each item addresses.

Forwarded-link interaction

Screenshot

The signed-out visitor must choose whether to adopt the account

The real built account widget received the same masked preview shape returned by the API. The bearer had already been removed from the address and did not appear in the document or widget shadow DOM. The dialog exposes only a masked email and offers Cancel and Continue.

Head at 25306e986: explicit confirmation before cross-device session adoption.After
Sauna account page with a centered confirmation dialog showing a masked email and Cancel and Continue buttons

Authentication state transitions

Table

Browser continuity determines whether user intent is required

The raw bearer remains the same single-use credential. Browser continuity changes only the gate before the atomic claim.

ScenarioServer resultWidget behavior
Matching initiation ID and nonce200 after atomic token claimSession is adopted without an extra prompt
Missing, stale or mismatched nonce409 with masked email; token remains unusedDialog requires Continue before a confirm request
Valid stored customer or impersonation sessionInbound token is not submittedCurrent session remains authoritative
Valid sibling session arrives during confirmationLate confirmation response is ignoredNewly adopted session remains authoritative
Junk sibling token fails validationSuccessful confirmation remains usableFailed candidate cannot discard the minted session

API confirmation boundary

Code

A mismatched browser receives preview data without consuming the bearer

The token candidate is tenant-scoped, unused and unexpired. Without a hash match or explicit confirmation, the route returns only a masked email and does not enter the claiming transaction.

Confirmation gate at implementation head b046e9fde.After
packages/api/src/routes/widget-auth.ts:835-864
.where(and(
  eq(magicLinkTokens.tokenHash, tokenHash),
  eq(magicLinkTokens.brandId, brandId),
  isNull(magicLinkTokens.usedAt),
  gt(magicLinkTokens.expiresAt, now),
  eq(customers.brandId, brandId),
  eq(customers.isArchived, false),
));

const browserInitiationMatches =
  candidates[0].browserNonceHash !== null &&
  suppliedBrowserNonceHash === candidates[0].browserNonceHash;
if (!browserInitiationMatches && !confirm) {
  return c.json({
    confirmationRequired: true,
    account: { maskedEmail: maskEmail(candidates[0].customer.email) },
  }, 409);
}

Atomic redemption

Code

Confirmation still enters the existing single-use claim

After the gate, the route locks the tenant-scoped customer and marks the selected token used only when it is still unused and unexpired. Concurrent requests cannot both return a claimed row.

Atomic claim at implementation head b046e9fde.After
packages/api/src/routes/widget-auth.ts:887-917
const verified = await db.transaction(async (tx) => {
  const [customer] = await tx
    .select()
    .from(customers)
    .where(and(
      eq(customers.id, candidates[0].customer.id),
      eq(customers.brandId, brandId),
      eq(customers.isArchived, false),
    ))
    .for("update");
  if (!customer) return null;

  const claimed = await tx
    .update(magicLinkTokens)
    .set({ usedAt: now, updatedAt: now })
    .where(and(
      eq(magicLinkTokens.id, candidates[0].tokenId),
      isNull(magicLinkTokens.usedAt),
      gt(magicLinkTokens.expiresAt, now),
    ))
    .returning({ id: magicLinkTokens.id });
  if (claimed.length === 0) return null;
  // Mint the tenant-bound customer session.
});

Widget session precedence

Code

A delayed confirmation cannot overwrite newer session authority

The bearer remains in a ref. Continue captures the current authority and applies its response only while the same provider, authority and pending token remain active. Successful session adoption invalidates those conditions.

Confirmation commit guard at implementation head b046e9fde.After
packages/widgets/src/lib/auth.tsx:960-978
const mlt = pendingMagicLinkTokenRef.current;
if (!mlt || !magicLinkConfirmation || confirmingMagicLinkRef.current) return;

const authority = magicLinkAuthorityRef.current;
confirmingMagicLinkRef.current = true;
setMagicLinkConfirmation({ ...magicLinkConfirmation, pending: true });
const result = await requestMagicLinkExchange(mlt, null, true);
if (
  !magicLinkMountedRef.current ||
  authority !== magicLinkAuthorityRef.current ||
  pendingMagicLinkTokenRef.current !== mlt
) {
  return;
}
if (result.kind === "signed-in") {
  setSession(result.token, result.customer);
}

Database migration

Code

The database stores only a nullable nonce hash

Migration 0191 adds one nullable fixed-length hash column. The raw browser nonce remains client-side and existing rows flow through explicit confirmation in the updated widget.

Complete migration SQL.After
packages/api/drizzle/0191_tearful_killmonger.sql:1
ALTER TABLE "magic_link_tokens" ADD COLUMN "browser_nonce_hash" varchar(64);

Permanent browser regression

Code

The default widgets-test command builds and exercises Chromium

The runner builds the current bundle and harness, ensures Chromium is available, starts a strict-port Vite preview, waits for readiness, and runs the forwarded and same-browser cases with browser tests enabled.

Browser runner at implementation head b046e9fde.After
packages/widgets-test/run-browser-tests.ts:20-78
if ((await run(["bun", "run", "build"])) !== 0) process.exit(1);
if (!existsSync(chromium.executablePath())) {
  if ((await run(["bunx", "playwright", "install", "chromium"])) !== 0) {
    process.exit(1);
  }
}

const preview = Bun.spawn([
  "bunx", "vite", "preview", "--host", host,
  "--port", String(port), "--strictPort",
], { cwd, stdin: "ignore", stdout: "inherit", stderr: "inherit" });

// Wait for account.html, then run the real browser suite.
const exitCode = await run(
  ["bun", "test", "magic-link-confirmation.browser.test.ts"],
  { RUN_BROWSER_TESTS: "1", WIDGETS_TEST_BASE_URL: baseUrl },
);

Confidence

Validation

Pass

API authentication behavior

36 tests passed with 115 assertions in widget-auth.test.ts, including masked preview, nonce match, explicit confirmation, replay rejection and email-verification effects

Pass

Database-backed customer authentication

4 magic-link capture tests passed with 30 assertions, and 31 session-revocation tests passed with 65 assertions against isolated PostgreSQL. Coverage includes stored nonce hash, same-browser redemption, cross-device confirmation, replay rejection and revocation interleavings.

Pass

Widget authentication and checkout behavior

37 tests passed with 199 assertions across auth.test.tsx and GiftCardPurchase.test.tsx, including both deferred session-authority race orderings

Pass

Real browser fixation coverage

The default widgets-test command passed 5 harness tests and 2 Chromium tests. The forwarded link required Continue and the requesting browser redeemed silently with its nonce.

Pass

Migration chain and schema convergence

191 migrations applied to an empty PostgreSQL database, a second migrate was idempotent, and the resulting 96-table, 56-enum schema matched drizzle-kit push

Pass

Type and style checks

API and widgets TypeScript checks exited 0. Biome reported no diagnostics across all changed source, test and package files. The 10-test credential-classification suite passed after the nonce hash was added to the dev-panel redaction policy.

Pass

Independent review

Independent code and security reviewers found and drove fixes for session-adoption races and skipped browser coverage. Focused final reviews reported no remaining material issue.

Limits

Known issues, risks, and gaps

Deployment coordination and legacy-client behavior are explicit

Widget and API deployment order matters A cached old widget does not understand the API's 409 confirmation response. Deploy the updated widget before or with the API. New widgets remain compatible with old API responses during the rollout, while enforcing confirmation once the API is current.

Existing unbound rows require confirmation Rows created before migration 0191 have no nonce hash. The updated widget treats them like cross-device links and keeps them usable through the explicit Continue action rather than silently redeeming them.

The UI capture uses a mocked API response The screenshot proves rendering, masked context, fragment removal and DOM secrecy in the production widget bundle. API enforcement and database mutation behavior are proven separately by focused unit and live PostgreSQL tests.