Skip to content
Source
Sign inSign up

[Bug]: Browser passkey bridge starts native sign-in ceremonies for silent requests #35959

Send to my agent
Opencoygeek(GitHub profile, opens in a new tab) opened this issue 0 comments

Summary

The browser passkey bridge treats a silent sign-in request as an interactive request and discards the mediation requirement before invoking the native backend. A site that asks to check credentials without involving the user can therefore enter the native passkey ceremony path. Importing the production bridge with fake native results reproduced the incorrect dispatch and credential return, while optional, required, and conditional controls behaved as intended.

Before submitting

  • Related issues: listed under Additional context; open and closed issue and PR searches found no matching silent-mediation bridge report.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/desktop

Observed behavior

CredentialsContainer.get({ publicKey, mediation: 'silent' }) invoked preview:passkey-get once with only the public-key options. The IPC arguments omitted the mediation requirement, and the fake successful native envelope became a PublicKeyCredential. Optional and required requests also invoked the bridge once; conditional requests correctly delegated to the original browser method.

The production main-process handler passes these public-key options to the macOS backend, which calls electron-webauthn.getCredential() with no silent-mediation input. This continuation is source evidence; no native sheet or real authentication was invoked in this investigation.

Reproduction

  1. Check out the commit below and save the script as repro-silent.mjs outside the checkout.
  2. Run node repro-silent.mjs /path/to/t3code with Node supporting direct TypeScript imports.
  3. Observe all three mediation controls passing, followed by the silent request invoking the ceremony callback and returning a credential.

The failure occurred deterministically in one execution. The script imports the actual production bridge and supplies minimal WebIDL constructor shims and synthetic native envelopes. It needs only Node, creates and removes disposable environment directories, leaves the checkout unchanged, and cannot open the system passkey sheet. Exit 1 means the defect was reproduced, 0 means silent access avoids the ceremony and returns null, and 2 means setup or a control failed.

Reproduction script: repro-silent.mjs
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
console.log(`Node ${process.version}; ${process.platform} ${process.arch}`);
const scratch = mkdtempSync(join(tmpdir(), 'passkey-silent-'));
for (const name of ['HOME','USERPROFILE','APPDATA','LOCALAPPDATA','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','T3CODE_HOME','TMPDIR']) {
  process.env[name] = join(scratch, name); mkdirSync(process.env[name]);
}
let status = 2;
try {
  if (!process.argv[2]) throw new Error('Pass the checkout path');
  const { installPasskeyBridge } = await import(pathToFileURL(resolve(process.argv[2], 'apps/desktop/src/preview/PasskeyBridge.ts')));
  let nativeGets = 0;
  globalThis.CredentialsContainer = class { async create() { return null; } async get() { nativeGets++; return null; } };
  globalThis.PublicKeyCredential = class {};
  globalThis.AuthenticatorAttestationResponse = class {};
  globalThis.AuthenticatorAssertionResponse = class {};
  const calls = [];
  installPasskeyBridge(async (channel, publicKey) => {
    calls.push({ channel, publicKey });
    return { success: true, data: { credentialId: 'AQ', clientDataJSON: 'Ag', authenticatorData: 'Aw', signature: 'BA', userHandle: '', extensions: {} } };
  });
  const credentials = new CredentialsContainer();
  const publicKey = { challenge: new Uint8Array([1]), rpId: 'example.com' };
  for (const mediation of ['optional','required']) {
    const before = calls.length;
    const result = await credentials.get({ publicKey, mediation });
    const pass = calls.length === before + 1 && result instanceof PublicKeyCredential;
    console.log(`${mediation} control: bridge invoked once and credential returned=${pass}`);
    if (!pass) throw new Error('Interactive control failed');
  }
  const beforeConditional = calls.length;
  await credentials.get({ publicKey, mediation: 'conditional' });
  if (calls.length !== beforeConditional || nativeGets !== 1) throw new Error('Conditional control failed');
  console.log('conditional control: original CredentialsContainer.get invoked once=true');
  const beforeSilent = calls.length;
  const result = await credentials.get({ publicKey, mediation: 'silent' });
  const extra = calls.length - beforeSilent;
  console.log(`silent: native ceremony invocations=${extra}; result is null=${result === null}; result is PublicKeyCredential=${result instanceof PublicKeyCredential}`);
  if (extra) console.log(`silent IPC arguments: ${JSON.stringify(calls.at(-1))}`);
  status = extra || result !== null ? 1 : 0;
} catch (error) { console.error(error.message); }
finally { rmSync(scratch, { recursive: true, force: true }); }
console.log(status === 1 ? 'RESULT: defect reproduced' : status === 0 ? 'RESULT: expected behavior' : 'RESULT: inconclusive');
process.exitCode = status;

Expected behavior

A silent credential lookup must suppress user mediation and return null when user involvement would be needed. Credential Management Level 1 §2.3.2 defines that behavior. §2.5.1 returns null for silent requests before external credential discovery, because external discovery requires mediation. This requirement applies to the credential container which the bridge replaces; a silent request must never reach an interactive native passkey ceremony.

Impact and scope

Impact: P2 - A site's explicit request to avoid user interaction enters the native ceremony path instead, allowing unexpected authentication UI and preventing the normal null-result fallback.

The reproduced scope is the custom browser passkey bridge's get() dispatch. The controls establish that optional/required interactive requests and conditional delegation are distinct. A page can avoid silent WebAuthn lookup and wait for an explicit sign-in action, at the cost of losing that lookup. NOT_ENOUGH_INFO: no real site or signed-app native sheet was tested, so the frequency of visible prompts in deployed applications is not established. Source gates the bridge on packaged macOS browser-passkey metadata; release capability enablement is tracked separately below.

Version

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. Repository pins include Electron 44.10.1 and electron-webauthn 1.3.1; native packages were not loaded by this reproduction.

Runtime and platform

Observed with Node v26.12.1, macOS darwin, arm64. Production TypeScript was imported directly; all native result data was synthetic and no account, keychain, or system sheet was used.

Interface

API: navigator.credentials.get() installed by the browser guest preload.

Earliest failure stage

Integration or transport

Relevant output or evidence

Node v26.12.1; darwin arm64
optional control: bridge invoked once and credential returned=true
required control: bridge invoked once and credential returned=true
conditional control: original CredentialsContainer.get invoked once=true
silent: native ceremony invocations=1; result is null=false; result is PublicKeyCredential=true
silent IPC arguments: {"channel":"preview:passkey-get","publicKey":{"challenge":{"0":1},"rpId":"example.com"}}
RESULT: defect reproduced

The dispatch in apps/desktop/src/preview/PasskeyBridge.ts#L248-L255 special-cases only conditional mediation. apps/desktop/src/preview/PasskeyBridge.ts#L205-L211 forwards only public-key options; apps/desktop/src/preview-pick-preload.ts#L9-L13 carries them over IPC. The main-process handler at apps/desktop/src/preview/Passkeys.ts#L201-L204 forwards them to apps/desktop/src/preview/PasskeyBackendMac.ts#L69-L75, which starts the native request.

Additional context

  • https://github.com/pingdotgg/t3code/pull/16952 — introduced the browser-site bridge and preserves conditional requests; this report concerns the separate silent mediation value.
  • https://github.com/pingdotgg/t3code/pull/5472 — addresses automatic conditional passkey prompts in T3 Connect's Clerk sign-in; this report concerns silent requests from arbitrary embedded-browser pages through a different bridge.
  • https://github.com/pingdotgg/t3code/issues/34661 — tracks enabling the macOS browser capability in releases; this report isolates the bridge's mediation contract once enabled.
  1. tanstack-source added labelsbug

  2. github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:denounced

Sign in to join this conversation.