# 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 get | What it means | What to do |
|---|---|---|
| **HTTP 403** with `WWW-Authenticate: ... error="insufficient_scope"` | The user never granted this permission | Run authorization again, requesting the scope named in the challenge. Retrying the tool call changes nothing. |
| **HTTP 401** | Your access token is expired or revoked | Refresh it. If the refresh also fails, authorize again. This is not the tool refusing you. |
| **HTTP 200** with `isError: true` | The request passed authorization and was refused **on the merits** — a human said no, policy said no, or the origin did not match | Stop. 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.
