The embed credential never carries the code

Updated · View the entry on sijie.xyz ↗

Verdict (2026-09-01): an embed authenticates a visitor session with a per-embed Ed25519 key wrapped as an EdDSA JWT, never with the access code. The JWT folds in the four anti-forgery elements — a bound origin, a Turnstile attestation, an expiry window, and a one-time nonce — and the server resolves it to the embed's code_id server-side. The plaintext code never enters the client. This reuses machinery already built: the owner-MCP Sigv1 stack (Ed25519 verify + withinSkew + Redis one-time nonce), the Turnstile CaptchaVerifier, and three existing revoke paths. golang-jwt/jwt/v5 is already in go.mod. Only net-new storage is a key-id + public-key pair of columns on the embed.

Motivating problem. A <standmeet-chat> widget runs on a site StandMeet does not control, and its session must land on an access code — the code is what carries the role → corpus ACL + capabilities (proven by embed-widget-carries-code-capabilities.spec.ts). But the code is a reusable secret: it is also printed on résumé QR codes and emailed to recruiters, and revoking it kills every one of those uses at once. Writing it into the widget's JS — public HTML on the host page — exposes that reusable secret to anyone who reads the page source. The origin allowlist already shipped narrows where a code works, but the Origin header it checks is only browser-enforced; a bare curl forges it. Two gaps: the code is exposed, and the guard is bypassable off-browser.

The design — a credential that indirects to the code

  • Per-embed Ed25519 keypair. The server generates it; only the public key is stored, on the embed row (embeds.public_key); the private key is embedded in that embed's served JS. The thing in the client is a signing key that maps to the code server-side — not the code. Many embeds may point at one code (each with its own key); a leaked key compromises one embed, never the code or its siblings.
  • EdDSA JWT as the envelope, folding every anti-forgery element into signed claims:
claim role
kid (header) selects the embed's public key
iss / sub the embed id
iat + exp short validity window (2 min — exp = iat + 120 in sdk/packages/embed/src/embed.ts:374; the burned jti lives 10 min)
jti one-time nonce, burned in Redis on first accept
origin host origin, read from window.location.origin at call time
cnf sha256(turnstile_token) — binds a Turnstile attestation into the signature
  • Server verification, in order — slots into preIssueBlocked/OriginCheckForCode, which already reads the Origin header and resolves the embed row:
  1. Pin alg=EdDSA server-side. Never let the token header choose the algorithm — the classic JWT footgun (alg:none, RS256→HS256 confusion where the public key is used as an HMAC secret). This is a hard rule, and a test.
  2. kid → embed public key from our store; unknown/missing kid → reject.
  3. Verify the EdDSA signature.
  4. exp/iat inside the window, else reject — a captured token expires.
  5. jti unseen → burn it; fail-closed if Redis is unreachable. (The owner-MCP path fails open — acceptable for the owner, wrong for a public untrusted surface.)
  6. origin claim == the browser-set Origin header (page JS cannot forge it) == the embed's allowlist.
  7. Verify the Turnstile token independently against the Turnstile secret; the cnf hash binds it to this JWT.
  8. Resolve embed → code_id → issue the session under that code's role.

Reused vs net-new

  • Reused: Ed25519 gen + public-key-only storage (owner keypair, owner/usecase/keypairs.go); the Sigv1 verify concept + withinSkew + Redis one-time nonce (VerifySigv1); the Turnstile CaptchaVerifier; revocation in three shapes (keypair hard-delete → instant fail / status='revoked' / the code→session Redis purge, RevokeCode→DeleteByCode). golang-jwt/jwt/v5 already vendored.
  • Net-new: a public_key column on embeds; generalizing the Ed25519 verifier to take an embed-scoped key source and wiring it into POST /api/v1/sessions (today that path authenticates by plaintext code equality only); a ~20-line EdDSA JWT signer in embed.ts (read origin, fetch a Turnstile token, sign).

The honest ceiling

The private key sits in public JS, so it is extractable — this is the physics of an embed on a third-party site and cannot be removed. What the design actually buys, precisely:

  • the code plaintext never appears in the JS, the request, or any response;
  • each embed's key is independently revocable — a leaked key is contained to one embed, revoked without touching the code or its siblings;
  • a captured request is replay-dead (one-time jti) and short-lived (exp);
  • a bare curl cannot call the endpoint without passing Turnstile per request, raising the cost from one line to a token-farming operation — and the limit_per_period cap plus revoke absorb what leaks through.

It is blast-radius containment + cost + a kill switch, not unbreakable secrecy. Saying otherwise would be security theater: a self-contained "anti-forgery" step inside the JS is worthless (curl re-runs the JS); the only real "prove you're a browser" primitive is a server-verified attestation (Turnstile), which is why it, not a fingerprint, is the browser gate.

Standing points

  • One embed per code. embeds.code_id is UNIQUE (shipped 2026-09-01) — a code is exposed by at most one embed, so its allowlist/key is the single authority. Relax this only deliberately; if reopened, "which key / which allowlist" must stay unambiguous.
  • Relation to what shipped. The origin allowlist (OriginAllowed, OriginCheckForCode, 403 origin_not_allowed) and limit_per_period are already in. This design is the next layer — it closes the two gaps the allowlist alone leaves: code exposure and off-browser bypass.

Built 2026-09-01. embeds.key_id/public_key columns (migration embed-signing-key); EmbedRepo.AuthByKeyID; access/usecase.VerifyEmbedToken (golang-jwt/v5, alg=EdDSA pinned, exp required, fail-closed jti on the shared Redis nonce store); wired into sessions_guard.go embedTokenBlocked; embed.ts browser signer (WebCrypto Ed25519) + admin one-time snippet reveal. Tests: embed-token-auth.spec.ts (8: valid→resolves to code, replay/origin-mismatch/off-allowlist/expired/wrong-key/alg-none/revoked all refused) + upgrade-embed-schema covers the third migration. Turnstile-binding (cnf) is the one deferred layer — captcha is off in dev; fold it in when turning captcha on for the public surface.

Scope correction (same day). The origin allowlist gates only the widget/token path. Direct plaintext-code access (QR / share link / paste) is not origin-gated — embedding a code must not lock its direct use, and with the JWT design the code never appears on a partner site anyway. The old plaintext-path origin check (OriginCheckForCode) was removed; guard: embed-direct-code-stays-open.spec.ts (direct use works from the instance origin, off-allowlist, and with no Origin header). This is the gate-granularity-removes-a-working-action failure: the gate was one notch too coarse and had quietly removed a working action (QR/direct) while CI stayed green.

Real-embed validation. Injected the snippet into a copied example.com served on a distinct origin (localhost:8090, in the allowlist): the widget mounts, loads /embed.js (CORS *), WebCrypto-signs, and gets a cross-origin 200 session — and the plaintext code is absent from both the page and /embed.js (only embed/kid/key). The same snippet served from a non-allowlisted origin (localhost:8091) → 403 + a clean "that did not go through". Answer content in dev is the mock LLM echoing the system prompt — real owner-voice answers need the real model (evals).

Origin: design conversation 2026-09-01; built and verified against a running standmeet-new (real cross-origin widget: 200 from the allowed origin, 403 from a non-allowlisted one; server accepts the browser WebCrypto signature).