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.
user-passkey is additive alongside user-sig — 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:
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 is the source of truth for the non-browser call shape.
The flow#
- 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. - Your app issues a challenge —
GET /challengeon your own deed guard, the same single-use challenge every deed mode uses. - Login.
signInWithPasskey()gets a WebAuthn assertion and assembles it into aDeed. - Your app verifies the deed with
DeedGuard/DeedVerifierand mints its own session, however it already does. See Verify a deed.
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 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:
originandchallengeare bound cryptographically — the browser itself writesorigin, and both are covered by the assertion signature, checked by the browser rather than a human reading a prompt.tenant,aud,expare 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).
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 —
user-sig, the compatibility path for any wallet. - Verify a deed — the relying-party side.
- Agent tokens — the
agent-zkdeed, for enrolled agents. - Sovereign tier § Passkey login — the full reference.
- Errors — every error code a relying party branches on.