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_idserver-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 TurnstileCaptchaVerifier, and three existing revoke paths.golang-jwt/jwt/v5is already ingo.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/subthe embed id iat+expshort validity window (2 min — exp = iat + 120insdk/packages/embed/src/embed.ts:374; the burnedjtilives 10 min)jtione-time nonce, burned in Redis on first accept originhost origin, read from window.location.originat call timecnfsha256(turnstile_token)— binds a Turnstile attestation into the signature
- Server verification, in order — slots into
preIssueBlocked/OriginCheckForCode, which already reads theOriginheader and resolves the embed row:
- Pin
alg=EdDSAserver-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.kid→ embed public key from our store; unknown/missingkid→ reject.- Verify the EdDSA signature.
exp/iatinside the window, else reject — a captured token expires.jtiunseen → burn it; fail-closed if Redis is unreachable. (The owner-MCP path fails open — acceptable for the owner, wrong for a public untrusted surface.)originclaim == the browser-setOriginheader (page JS cannot forge it) == the embed's allowlist.- Verify the Turnstile token independently against the Turnstile secret; the
cnfhash binds it to this JWT.- 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 TurnstileCaptchaVerifier; revocation in three shapes (keypair hard-delete → instant fail /status='revoked'/ the code→session Redis purge,RevokeCode→DeleteByCode).golang-jwt/jwt/v5already vendored. - Net-new: a
public_keycolumn onembeds; generalizing the Ed25519 verifier to take an embed-scoped key source and wiring it intoPOST /api/v1/sessions(today that path authenticates by plaintext code equality only); a ~20-line EdDSA JWT signer inembed.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
curlcannot call the endpoint without passing Turnstile per request, raising the cost from one line to a token-farming operation — and thelimit_per_periodcap 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_idis 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, 403origin_not_allowed) andlimit_per_periodare 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).