The three refusals that look alike and mean opposite things, and why waiting_for_approval is a success.

View as Markdown

Failure modes

Most of what goes wrong with an agent sign-in is not cryptographic. It is an agent misreading a refusal — retrying something terminal, or giving up on something that was about to succeed.

Three refusals that look alike and mean opposite things

What you getWhat it meansWhat to do
HTTP 403 with WWW-Authenticate: ... error="insufficient_scope"The user never granted this permissionRun authorization again, requesting the scope named in the challenge. Retrying the tool call changes nothing.
HTTP 401Your access token is expired or revokedRefresh it. If the refresh also fails, authorize again. This is not the tool refusing you.
HTTP 200 with isError: trueThe request passed authorization and was refused on the merits — a human said no, policy said no, or the origin did not matchStop. Report it. Re-authorizing will not change it.

The distinction: 403 and 401 are about you, and are fixed by re-authorizing. A denial is about the request, and is not fixed by anything an agent can do.

Only a transport-level 403 carrying error="insufficient_scope" starts an OAuth step-up. A 200 carrying isError: true is handed to the model as text and starts nothing — which is why the scope refusal deliberately climbs out of the JSON-RPC layer to become an HTTP status.

waiting_for_approval is a success

It is the most misread result in the product. It means: a human is being asked, on their phone, right now. Not a rate limit, not a transient error, not something to work around.

  • Sleep retryAfterMs. Do not poll faster.
  • Then call passkey_assert again with byte-identical arguments, at most once per interval, until it comes back signed or denied or the approval expires (about 90 seconds). "Once more" means one call per interval until it is decided — a slow poll, not a single attempt and not a tight loop.
  • Identical arguments rejoin the same pending question. Any change — a different origin, re-encoded clientDataJSON, a different credential — raises a *new* question and pushes a second notification at the same person.

An agent that "tries something slightly different" on a pending approval buzzes a human repeatedly for one sign-in. That is how an agent gets its access revoked, and rightly.

passkey_approvals tells you what is still outstanding if you lose track.

origin_rejected

The origin you sent is not one a human observed when the passkey was enrolled, or it disagrees with the origin inside the clientDataJSON.

This is the control working. A remote agent can only ever *assert* an origin, so the service checks the assertion against what the enrolment actually saw. Do not try other origins until one is accepted; that is precisely the behaviour the check exists to stop.

no_assertion — the page never asked

The most common real-world failure, and not a failure of the crypto at all: the page never called navigator.credentials.get(), so there was nothing to sign.

Sites almost always need something first — an email typed, a button clicked, an option chosen from behind "Try another way" or "Other ways to sign in". That is what the connector's notes are for. Read them, drive the page, and try again.

The site rejected a valid signature

If the signature was produced and the site still refuses it, do not sign again. An identical signature will be rejected identically. Likely causes:

  • The connector is in silent mode and the site requires UP=1/UV=1. It is not lying about those flags to make the sign-in work.
  • The clientDataJSON was rebuilt or re-encoded rather than passed through byte-for-byte.
  • The site wants a further step it did not auto-submit — a second factor, or a "continue".
  • The ceremony ran inside a cross-origin iframe. The shim writes crossOrigin: false, so that case produces a clientDataJSON the relying party will not accept. It is a known gap: report it rather than retrying.

The browser was refused before any of this

Google, in particular, refuses browsers it detects as automated, before the passkey ceremony is ever reached. A persistent profile with a real user-data directory gets through where a bare headless launch does not. This is a property of the site, not of the passkey.