technical passkeys, delegation, AT Protocol, threat model
Identity: a passkey and an AT Protocol account
Sign-up does two things. It registers a passkey by WebAuthn with residentKey: required and ES256 as the only accepted algorithm, so the passkey is a P-256 key held by the person's device. Attestation is none and user verification preferred: attest asks the device nothing about itself and accepts any authenticator, whether a phone, a Linux desktop, a password manager or a hardware key. (WebAuthn attestation, the authenticator vouching for its own make, is unrelated to attest's records, which are people vouching for URLs and keys.) And it creates an AT Protocol account on our data server (PDS): a did:plc in the public PLC directory, a repository, and a handle <name>.attest.monster bound to the DID by a DNS TXT record at _atproto.<handle>. The account's password is a random server-side secret nobody sees; the passkey is the person's key. We store the credential id, the passkey's public key, its signature counter, the handle and the DID. There is no email and no password the person uses.
The passkey's public key is also expressed as a did:key (multicodec prefix for P-256, 0x1200, then the 33-byte compressed point, base58btc with a z prefix; these start did:key:zDna). The account's identity is the did:plc; the passkey is what controls the account's delegations. A passkey cannot itself be the PLC rotation key, because AT Protocol rotation keys sign raw bytes and WebAuthn signs a challenge structure, so the rotation key for accounts made here is held by the service and backed up off the server.
Delegation to a device key
WebAuthn signatures are bound to the relying party's origin and each one prompts the user. A site on another origin cannot ask your passkey to sign, and a prompt per upvote is unusable. So the passkey signs a delegation instead.
delegation {v:2, type:"delegation", root, device, devKey:{x,y}, originHash, from, until}
originHash = sha256(canonical({origin, salt})) // origin and a 16-byte salt travel beside it, never inside it
- device is a P-256 key the integrating page generated with WebCrypto, private half non-extractable, stored in that origin's IndexedDB. It never leaves the browser and never reaches us in private form. devKey is its public JWK.
- originHash commits to the bare origin of the page that will use the delegation, without naming it. The site's session holds the origin and the salt, so the site can prove the binding; nobody else can tell which site it was. We keep the origin privately, to refuse a record whose socket origin differs from it (a request with no origin is refused) and to show the person their own sign-ins. A delegation enters the public log only when something signed with it is published. Before 2026-10-02 (v1) the origin was in clear; those entries naming other sites were deleted.
- from must be within ten minutes of server time; until is at most 90 days later. The login page issues 30.
The delegation's id is the SHA-256 of its canonical JSON. That id, as bytes, is the WebAuthn challenge; the passkey's assertion over it is the signature on the delegation. Sign-in therefore is the act of delegating, and a returning user sees one prompt. The assertion (authenticatorData, clientDataJSON, signature) is stored with the delegation and appended to the log, so anyone with the root's public key can verify the delegation later, offline. The signature counter is updated and monotonic.
On a page of another origin, the login page runs in a popup on our origin (so the passkey works), receives the page's device DID and public JWK in the query string, and posts the finished session back with postMessage to the origin in the delegation. On our own origin it is a redirect.
Agents
An agent is a program acting for a person. It holds its own P-256 key, and the person's passkey signs a permission for it, as it signs a delegation for a browser:
agent {v:1, type:"agent", root, agent (did:key), agentKey:{x,y}, name, purpose,
permissions:["repo:monster.attest.comment?action=create&action=delete", …], limits:{perDay}, from, until}
- permissions are AT Protocol permission strings (atproto.com/specs/permission), limited to attest's own record types. A write outside them is refused; so is a write past perDay in the last 24 hours, or after until (at most 90 days) or a revocation.
- Public at once. Unlike a browser sign-in, an agent permission is published when signed: the log carries it with the passkey's assertion, and
/agent/<id>serves it. Anyone holding something the agent signed can see whom it acts for and within what bounds. - No trust of its own. Records go into the person's repository with by = the person; a vote or vouch is one per subject per repository, so any number of agents add nothing one does not. Comments are shown "via" the agent. Agents cannot make admin or owner requests, and cannot delegate further.
- The hand-off follows the user-claimed flow of WorkOS's auth.md: the agent posts its public key and request, gets a code and a link; the person opens the link on attest, checks the code, may narrow the request, and approves with their passkey; the agent polls for the result. Pending requests live 15 minutes, in memory, unpublished. Where auth.md ends in a bearer token, this ends in a signed public permission naming the agent's own key, so each record remains attributable.
- Transport. Agents use plain HTTP and JSON (
/agent/request,/agent/poll/:token,/agent/attest,/agent/retract), since they are programs, not browsers; the browser pages keep the socket. The agent endpoints accept only agent permissions, so a site sign-in cannot be replayed there without its origin. - Threats. A stolen agent key can write within its permission until revoked or expired; keep permissions narrow and short. The permission cannot be widened without a new passkey signature. Whether an agent is "really" an AI is not something attest tries to settle; what it records is who answers for it. The closest general form is UCAN, capabilities delegated key to key; attest's permission is a one-step version of that, with no further delegation.
Records in repositories
A record is an AT Protocol record in the author's repository, in one of our lexicons, monster.attest.{vote, comment, statement, vouch, claim, verification, bookmark}, published at _lexicon.attest.monster. Each carries the author's inline signature in the shared app.certified.signature.defs#inline shape, as in badge.blue and Hypercerts: the device key signs the 36-byte CIDv1 (DAG-CBOR, SHA-256) of the record with its signatures removed and a $sig object {$type, key, repository} inserted. ECDSA P-256, raw r||s, low-S. Because the repository DID is inside what is signed, a signature cannot be replayed into another repository.
The client builds and signs the record; the service checks the signature against the delegated device key and the repository DID, checks the delegation is live and was issued for the calling origin, validates the record against the lexicon, and writes it into the repository through the PDS. Votes, vouches, claims and bookmarks use a record key derived from the subject, so there is one per author per subject; comments and statements use time-ordered keys. Retraction deletes the record, authorised by a device-key signature over {type:"retract", uri, at}.
The service's index (SQLite) is derived from the repositories. It follows the PDS's subscribeRepos firehose: a record in our collections written by any client is verified against its repository and indexed, an unsigned one is ignored, a deletion retracts, and an account's deletion or deactivation removes its records from all counts. Delegations, revocations and passkey changes, which are not repository records, are kept in the index's own log with their passkey assertions.
Identities proven on Bluesky are linked, and the trust they have already declared on AT Protocol is imported as edges: Tangled vouches and denouncements (sh.tangled.graph.vouch, subject in the record key), Tangled follows and Bluesky follows. Each is kept as its own kind so a scorer can weigh them differently. Imports refresh every six hours.
What we take from AT Protocol, and where we differ
| atproto | here |
|---|---|
| DIDs, PLC directory, handles by DNS | Used as is: every account is a did:plc with a DNS-bound handle. |
| PDS: a signed Merkle repository per account, relayed by a firehose | Used as is: we run a PDS; records live in repositories; our index follows the firehose. |
| Lexicons and lexicon resolution | Our own monster.attest.* lexicons, published for resolution. Records address URLs, DOIs and content hashes, which the shared vocabularies do not yet. |
Inline signatures (badge.blue, app.certified) | Used as is, and required: every record carries the author's own signature from a key they hold, not only the repository's commit signature. |
Labelers: queryLabels, subscribeLabels | Planned: scores published as labels so any subscribing client can consume a root's view. |
| OAuth with DPoP and PAR | Used to sign in with an existing Bluesky or AT Protocol handle, asking only for repo:monster.attest.*; the passkey and per-site device key still sign every record. |
| Tangled vouches, Bluesky follows | Read in as weighted trust edges for proven identities. |
Other borrowings: the NIP-73 identifier grammar for subjects; SIWE's idea that a login is a signed, origin-bound, time-stamped message; Farcaster's per-app signer keys under one root.
Threat model, as it stands
| threat | today |
|---|---|
| Stolen device key (XSS on the integrating site) | Can sign as the user on that origin until the delegation expires (≤90 days) or the root revokes it from /me: a revoke action signed by the passkey, appended to the log; later records under that delegation are refused. Cannot act on other origins. |
| Stolen delegation without the device key | Useless: the device private key is non-extractable and never transmitted. |
| Replay of a record | The signed input includes the repository DID, so a record cannot be replayed into another repository; within one, votes, vouches and claims have subject-derived keys, so a duplicate is a no-op. |
Spoofed Origin header from a non-browser client | The origin check is a browser guarantee, not a cryptographic one. A non-browser attacker still needs the device key. Binding the origin into the signed record is a planned tightening. |
| Sybil keys | Free to create. Counts today are raw and say so. Bounded-flow scoring from a root is the answer and is the next milestone after vouches exist. |
| Whitewashing (burn and return) | By design. Newcomers start at zero; the cost is paid in time. See Friedman and Resnick. |
| Lost passkey | Lost account unless a second passkey was added: a new device registers one, and an existing passkey signs add-key to adopt it. Removing one needs a signature from another. Key export is planned. |
| Our server lies or rewrites | Repositories are signed Merkle trees relayed to others, so a rewrite is visible; and each record carries its author's own signature, which we cannot forge. The service does hold the repositories' signing and rotation keys for accounts it hosts, which is why the author's inline signature is required. |
| The service stops | Identities are in the public PLC directory; the rotation key and repository data are backed up off the server; records are copied by relays. The service can move to another host. |
| Reads reveal what you look at | True for a site's own reads through us. The planned browser client scores from a copy of the records or asks by hash prefix. |
Root actions
Sign-in is one instance of a general shape: the passkey signs an action by signing a WebAuthn assertion whose challenge is the action's SHA-256. The others are revoke (a delegation), add-key and remove-key (passkeys on the account). Each is kept in the index's log with its assertion, so account history can be replayed and checked. The service offers only the account's own passkeys in the browser prompt, and never the one being removed.
Proof of control and the service key
A claim record says a key controls a handle or URL. The token attest-proof:<claim cid> goes where only the controller can put it: the page itself or /.well-known/attest.txt for a website, a TXT record at _attest.<domain>, a public gist, a Bluesky bio. The service fetches, and if it finds the token it writes a verification record into its own repository (attest.attest.monster, DID at /service), signed inline by its own P-256 key, with the evidence URL. Any other account may verify a claim by writing its own verification; each counts as far as its author is trusted. A verified Bluesky claim also links that identity for the graph read-in above.
Practices borrowed from neighbours, and what is still to adopt
| practice | source | here |
|---|---|---|
| Bind the context into the signed payload so a signature cannot be replayed elsewhere | badge.blue binds the repository DID into the CID it signs | Adopted for the repository: its DID is inside every signature. The site origin is carried by the delegation and enforced by the server, not yet inside the record. |
| Low-S normalisation of ECDSA signatures | badge.blue; Bitcoin before it | Adopted: inline signatures are low-S, and verification refuses high-S. |
| Content-addressed ids in a form other systems can reference | badge.blue's CIDv1 over DAG-CBOR; AT Protocol generally | Adopted: records are addressed by their at:// URI and CID like any AT Protocol record. |
| Third-party attestations as separate proof records the subject's owner may or may not reference | badge.blue remote attestations | Different by design: every record here is public in one log regardless of the subject's wishes. The subject's control is the root-as-parameter score, not editing what others said. |
| A shared vocabulary of attestation records: signatures list, remote signature proofs, badge awards about a DID or a record, follows, evaluations with a bounded numeric score, acknowledgements that accept or reject a link | Hypercerts' lexicons and the app.certified.* namespace they share | Partly: the inline signature shape is shared. Our record types are our own lexicons because they address URLs and hashes; proposing that subject shape to the shared vocabulary is on the roadmap. |
| Access policies over a vouch graph ("only keys my graph vouches for") | socialweb.computer's policy engine | The consumer we want for the planned labeler output. |
| Stackable, subscribable labels | AT Protocol labelers | Chosen as the publication format for scores, once scoring exists. |
| Per-app signer keys under one root | Farcaster | Adopted as per-site device keys under a passkey. |
| Signed-once, proven-by-fetch identity claims | Keybase, Webmention | Adopted as proof of control. |
Canonical JSON
Used only for delegations, agent permissions, revocations and retraction authorisations, which are not repository records: object keys sorted at every level, arrays in order, undefined dropped, no whitespace. Repository records are hashed as DAG-CBOR, as AT Protocol specifies.
Source: github.com/orbitalfoundation/attest. Reading behind these choices is in the repository's reference/ and devlog/.