docs / guide

view as .md

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#

  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 challengeGET /challenge on your own deed guard, 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.

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:

  • 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).

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#

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.