Skip to content

Account Pairing

A document created over the unauthenticated MCP endpoint has no owner. Ownership is established afterward by a claim handshake that binds the draft to a user account. This is account pairing — structurally similar to device pairing (a short code shown on a surface the requester can't read authorizes an untrusted client), except the durable principal is the user account, not a device.

In brief. An anonymous create_document writes an unclaimed draft and hands the model a claim token (S) and a short claim link (R). The user opens /claim/<R>, which resolves R to the document GUID H, signs in, and relays the six-digit device code (C) the server minted for that browser. The model replays claim_document(S, C) and the server binds the draft to the account. The handshake ties forward access to the account and resists an attacker who never saw the chat — chiefly prompt injection — but only under out-of-band delivery (see Delivery mode and trust). S survives the claim as the key for update_document edits.

The elements

S, R, and C are stored only as hashes, never in plaintext; H is the raw row id.

  • S — claim token. Model-carried; no human transcribes it. Resolves the row at claim_document and again at update_document, so it lives for the draft's lifetime — ending only when the draft expires and the row is swept. (Save Copy promotes a copy into the user's library; it does not delete the draft or retire S.)
  • C — device code. Six digits, single-use, rate-limited — the only human-transcribed element. Salted by H and verifiable only against that row. Bound to the authenticated account that minted it (pending_owner_id); a different account viewing it trips tamper.
  • H — document GUID. The 128-bit row id. Pre-claim it serves no content (minting lives on /claim/<R>); post-claim it is the stable, owner-gated document address (/ephemeral/<H>).
  • R — claim link. A short code that travels in clear chat text — a chat-safe handle for the draft, not a separate authority. Its page, /claim/<R>, is where claiming happens: it gates sign-in and mints the device code C, and serves no document content pre-claim. Only once the model completes claim_document does the page redirect the now-owner to the viewer at /ephemeral/<H>. Durable: it keeps resolving until expiry, so a re-click always lands.

Flow

  1. create_document(content) → row written owner_id = NULL with S, R, and a 5-minute deadline. Returns S and the /claim/<R> link to the model; records the delivery mode.
  2. The model asks the user to open the link, sign in, and paste the code into chat.
  3. /claim/<R> resolves RH, gates sign-in, mints C, binds it to the authenticated account (pending_owner_id), and extends the row into the claim window.
  4. The user pastes C into chat; the model calls claim_document(S, C).
  5. The server verifies S and C, promotes pending_owner_id → owner_id, and bumps expires_at to +24 h. C is consumed; S and R stay live.

After the claim, /claim/<R> redirects the owner to /ephemeral/<H>, which renders the draft with a Save Copy action that promotes it into the user's documents.

sequenceDiagram
    participant Mdl as Model
    participant Server
    participant Usr as User (signed in)

    Mdl->>Server: create_document(content)
    Server->>Server: write draft, owner_id = NULL (5-min deadline)
    Server-->>Mdl: claim token S, claim link R
    Mdl->>Usr: "open /claim/R, sign in, send me C"
    Usr->>Server: open /claim/R (authenticated)
    Server-->>Usr: device code C
    Usr->>Mdl: device code C
    Mdl->>Server: claim_document(S, C)
    Server->>Server: owner_id = user, expires_at +24h
    Note over Usr,Server: Save Copy promotes draft to documents

Post-claim updates (update_document)

update_document(S, content) resolves the claimed row by S and overwrites its content in place — no second handshake, link, or device code — and bumps expires_at back to the full claimed window. An owner_id IS NOT NULL guard keeps it strictly post-claim; the row (and S) live until the draft's expires_at, when a sweep removes them — Save Copy promotes a copy into the user's library but leaves the draft live, so update_document keeps editing the draft (not the saved copy) until it expires. Possession of S is the write capability: the call does not re-check the session. It is write-only — it cannot read the document or re-own it — and does not widen the injection surface, since an attacker able to read S from model context can already call it.

Principal model

Sign-in is the claim gate; the user account (owner_id) is the durable principal. During the mint→claim window, C binds to the authenticated account (pending_owner_id): only the account that minted C is shown it (others get a tamper page), so only that user completes the claim — from any browser. Post-claim, forward access is gated by owner_id, consistent with the rest of the app's auth.

Delivery mode and trust

How the claim link reaches the user sets the injection boundary, and the server decides it (never the model):

  • Out-of-band — a host UI surface the model cannot read: injection captures S but never R/H, so it cannot mint C or claim. Full trust.
  • In-band — the link rides in model text: injection sees both S and R, equivalent to handing over the claim — it can open /claim/<R>, authenticate as an attacker account, mint its own C, and claim. Reduced trust.

Today the MCP endpoint is stateless Streamable HTTP with no host surface that can show the claim link out of band, so every MCP-created draft is recorded in_band — the reduced-trust path. Out-of-band (full trust) requires a host UI that displays the claim link where the model can't read it.

Trust is derived from the recorded delivery_mode at decision time, not stored as a separate flag; delivery_mode lives on the ephemeral row only and is not carried onto the document on promotion.

Known limitation: mint-race

Minting is first-mint-wins: before anyone mints, the first authenticated visitor to /claim/<R> — any account — binds it and is shown the code. An attacker who holds R and wins the race makes the legitimate user trip the tamper page, and the draft expires unclaimed. This is an availability grief, not disclosure: staging a claim is worthless without S, which is model-carried and never enters the URL, logs, or sign-in callback. (Under in-band delivery an injection holds S too — that is the reduced-trust case above.)