2026-09-23·by Sijie Wang#software#project#standmeet

embed-credential-never-carries-the-code

The embed credential never carries the code

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:
claimrole
kid (header)selects the embed's public key
iss / subthe embed id
iat + expshort validity window (2 min — exp = iat + 120 in sdk/packages/embed/src/embed.ts:374; the burned jti lives 10 min)
jtione-time nonce, burned in Redis on first accept
originhost origin, read from window.location.origin at call time
cnfsha256(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, RevokeCodeDeleteByCode). 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).

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

embed-credential-never-carries-the-code