# Passkey login (`user-passkey`)

A human signs in with a **passkey** (WebAuthn) instead of a wallet, and your app gets the
same shape of stable, app-scoped **pseudonym** `user-sig` gives — not their identity. The
browser's own authenticator signs, the SDK mints a **deed**, and your app verifies it
directly against the on-chain registry, no issuer or server in between — see [Concepts](concepts.md).

`user-passkey` is **additive alongside [`user-sig`](wallet-login.md)** — pick it when you
can require a passkey; keep `user-sig` for a caller who only has a wallet.

## TypeScript: `registerPasskey` / `signInWithPasskey`

`@grantor/sdk` exports both ceremony functions:

```ts
import { registerPasskey, signInWithPasskey } from "@grantor/sdk";

// once, at enrollment — persist credentialId/credentialPubkeyHex against this human
const { credentialId, credentialPubkeyHex } = await registerPasskey({
  rpId: "app.example.com",
  userName: "alice",
  userId: "alice-internal-id",
});

// each login
const deed = await signInWithPasskey({
  credentialId,
  credentialPubkeyHex,
  tenant: tenantId,
  aud: audience,
  origin,           // the origin YOU are actually running on
  challenge,        // from your app's own /challenge
  exp,
});
```

`signInWithPasskey` above is convenience glue built on top of one pure, cross-language
**assembler**, `mintUserPasskeyDeed`/`mint_user_passkey_deed`/`MintUserPasskeyDeed`/
`mint_user_passkey_deed` (TS/Python/Go/Rust) — pure bytes-in-deed-out, no live authenticator
involved, which is what lets a server-side language mint (and test) a `user-passkey` deed
from already-captured assertion bytes. It ships in **all four** languages, TypeScript
included: `@grantor/sdk` re-exports `mintUserPasskeyDeed` at the top level, the same
low-level re-export `mintUserDeed` gets for `user-sig`. Only the **live browser ceremony**
(`registerPasskey`/`signInWithPasskey`) is TS-only, by platform reality: there is no
non-browser WebAuthn authenticator for another language to wrap. The Rust snippet in
[Sovereign tier § Passkey login § The assembler — four
languages](../sovereign-tier.md#passkey-login-user-passkey) is the source of truth for the
non-browser call shape.

## The flow

1. **Registration, once per `(rpId, human)`.** `registerPasskey()` creates the passkey and
   returns the credential id and public key for your app to persist against that human.
2. **Your app issues a challenge** — `GET /challenge` on your own [deed
   guard](verify-tokens.md), the same single-use challenge every deed mode uses.
3. **Login.** `signInWithPasskey()` gets a WebAuthn assertion and assembles it into a
   `Deed`.
4. **Your app verifies** the deed with `DeedGuard`/`DeedVerifier` and mints its own
   session, however it already does. See [Verify a deed](verify-tokens.md).

## Why this mode exists

`user-sig` scopes a holder's key per origin, but the control that a `personal_sign` was
produced *at* the claimed origin is a **human reading a signing prompt** — a hostile page
can request the *victim's* origin-scoped message, and the wallet cannot tell who is asking.
`user-passkey` replaces that human-read prompt with **browser-enforced, cryptographically
signed origin binding**: WebAuthn writes the origin into the assertion itself, twice,
independently, and a hostile page cannot make either field lie about the page it is really
running on. See [Sovereign tier § Why the seed is
origin-scoped](../sovereign-tier.md#why-the-seed-is-origin-scoped) for the full before/after —
`user-sig` itself is **not** retroactively fixed by this mode existing; it keeps its
documented residual.

## The binding-model caveat — read before relying on this

WebAuthn only lets your app control the **challenge**; the assertion signs
`authenticatorData ‖ SHA256(clientDataJSON)`, which carries only
`type`/`challenge`/`origin`/`crossOrigin`. So:

- **`origin` and `challenge` are bound cryptographically** — the browser itself writes
  `origin`, and both are covered by the assertion signature, checked by the browser rather
  than a human reading a prompt.
- **`tenant`, `aud`, `exp` are bound by your verifier's policy plus the single-use
  challenge**, not by the assertion itself. Your verifier issued *this* challenge for *its
  own* `(tenant, aud, origin)` and burns it exactly once, so a captured assertion cannot be
  retargeted to a different verifier, tenant, or audience.

Full detail, including the WebAuthn verification steps your app's verifier runs, in
[Sovereign tier § Passkey login (`user-passkey`)](../sovereign-tier.md#passkey-login-user-passkey).

## What this proves — and what it does not

Like `user-sig`, `user-passkey` proves *"I control this credential"*, not membership —
permissionless login is open by definition; anyone can register a passkey. There is no
on-chain user revocation, because there is no on-chain user state to revoke; an RP that
wants to ban someone bans the `sub` on its own side. Requiring membership is a separate,
gated mode (allowlist / token-gate / DAO) that is not built yet.

A human who used `user-sig` and later registers a `user-passkey` is a **different subject**
(different key material) — no automatic linking, the same as using two different wallets.

## See also

- [Wallet login](wallet-login.md) — `user-sig`, the compatibility path for any wallet.
- [Verify a deed](verify-tokens.md) — the relying-party side.
- [Agent tokens](agent-tokens.md) — the `agent-zk` deed, for enrolled agents.
- [Sovereign tier § Passkey login](../sovereign-tier.md#passkey-login-user-passkey) — the
  full reference.
- [Errors](errors.md) — every error code a relying party branches on.
