Signed Metadata (Embed Widget)

How to vouch for the metadata your pages attach to an embedded interview, by signing it on your server.

September 26, 2026

Start with the Embed Widget page — you need a publishable key and its signing secret before anything here applies.

When you need this

Anything in data-metadata comes from the page, so a visitor can change it in dev tools before the interview starts. That is fine for context you only want to read next to the transcript.

It is not fine when the interview acts on the value — asking enterprise-only questions, skipping a section for trial users, or splitting results by plan. For those, your server mints a short-lived JSON Web Token (JWT) signed with the key's signing secret, and the page passes it as data-signed-token instead of data-publishable-key. Deutero verifies the signature, so it knows the values came from your backend and not from the browser. Verified values are stored with provenance token (shown as verified in the dashboard) and override any page-supplied value of the same name.

How the flow fits together

  1. Keep the signing secret (dtro_sk_…) in your server's secret manager. It must never reach the browser — anyone holding it can forge metadata for your studies.
  2. When you render the page hosting the widget, mint a token for the currently signed-in end user. Mint one per page load; do not cache it in a cookie or reuse it across users.
  3. Render the widget script tag with data-signed-token="…" in place of data-publishable-key. Everything else (data-survey-id, data-mode) stays the same. The token's kid header tells Deutero which key — and therefore which organization, study scope and allowed origins — to check it against.
  4. If your page loads the widget after render (a single-page app, a modal opened on click), fetch the token from your own endpoint first and set the attribute before you append the script element.

What the token must contain

Field Where Value
alg header HS256 only. Other algorithms are rejected.
typ header JWT
kid header Your publishable key (dtro_pk_…). This is how Deutero finds the matching signing secret — a token without it cannot be verified.
sub claim Your own stable identifier for the end user (not their email, unless that is your id). It links repeat interviews by the same person.
iat claim Issued-at, Unix seconds. Up to 60s of clock skew is tolerated, so a slightly fast server clock is fine.
exp claim Expiry, Unix seconds. At most 900s (15 minutes) after iat; 5–10 minutes is a good default. The token only has to survive until the visitor starts the interview.
metadata claim A flat object of the values you want treated as verified.

Metadata limits: at most 32 keys; names matching ^[A-Za-z_][A-Za-z0-9_]{0,63}$; scalar values only (string ≤ 1 KB, number, bool, null) — no nested objects or arrays; total serialized size within the key's metadata size limit. A bootstrap that exceeds any of these is rejected outright, so validate before you sign.

Minting the token on your server

Python

pip install pyjwt

import os
import time

import jwt  # PyJWT

PUBLISHABLE_KEY = os.environ["DEUTERO_EMBED_PUBLISHABLE_KEY"]  # dtro_pk_...
SIGNING_SECRET = os.environ["DEUTERO_EMBED_SIGNING_SECRET"]    # dtro_sk_...


def mint_embed_token(user) -> str:
    now = int(time.time())
    return jwt.encode(
        {
            "sub": str(user.id),        # your stable end-user id
            "iat": now,
            "exp": now + 600,           # 10 minutes; hard cap is 900
            "metadata": {
                "plan": user.plan,           # string, <= 1 KB
                "seats": user.seat_count,    # number
                "is_trial": user.is_trial,   # bool
            },
        },
        SIGNING_SECRET,
        algorithm="HS256",
        headers={"kid": PUBLISHABLE_KEY},
    )


# Flask: hand the token to a page that loads the widget client-side.
@app.get("/embed/interview-token")
@login_required
def embed_interview_token():
    return {"token": mint_embed_token(current_user)}

TypeScript

npm install jose

import { SignJWT } from "jose";

const publishableKey = process.env.DEUTERO_EMBED_PUBLISHABLE_KEY!; // dtro_pk_...
const signingSecret = new TextEncoder().encode(
  process.env.DEUTERO_EMBED_SIGNING_SECRET!,                       // dtro_sk_...
);

export async function mintEmbedToken(user: AppUser): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({
    metadata: {
      plan: user.plan,          // string, <= 1 KB
      seats: user.seatCount,    // number
      is_trial: user.isTrial,   // bool
    },
  })
    .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: publishableKey })
    .setSubject(user.id)        // your stable end-user id
    .setIssuedAt(now)
    .setExpirationTime(now + 600) // 10 minutes; hard cap is 900
    .sign(signingSecret);
}

// Express: hand the token to a page that loads the widget client-side.
app.get("/embed/interview-token", requireLogin, async (req, res) => {
  res.json({ token: await mintEmbedToken(req.user) });
});

Putting the token on the page

Render the token straight into the script tag from your server-side template:

<!-- your server mints <signed-token> (JWS, HS256) with the key's signing secret -->
<script async src="https://app.deutero.ai/widget/v1/deutero-widget.js"
        data-signed-token="<signed-token>"
        data-survey-id="<survey uuid>"
        data-mode="chat"></script>

Or, if the widget is loaded later by client-side code, fetch the token from your endpoint first:

const { token } = await fetch("/embed/interview-token", {
  credentials: "include",
}).then((r) => r.json());

const s = document.createElement("script");
s.async = true;
s.src = "<widget src above>";
s.dataset.signedToken = token;   // instead of dataset.publishableKey
s.dataset.surveyId = "<study id>";
s.dataset.mode = "chat";
document.body.appendChild(s);

A tag may carry data-metadata as well, for values that do not need to be verified. Where a name appears in both, the signed value wins.

If a bootstrap is rejected

  • Expired or too long-lived token — exp must be within 15 minutes of iat, and the token must still be in date when the visitor loads the page.
  • Bad signature or missing kid — check you signed with the secret belonging to the publishable key in the kid header. The secret is shown only once, at creation; if you lost it, create a new key and revoke the old one.
  • Wrong origin — the page's origin must exactly match one of the key's allowed origins (scheme, host and port).
  • Wrong study — a study-scoped key rejects a bootstrap naming any other study.
  • Revoked key — revoking takes effect on the next bootstrap; interviews already collected keep their recorded provenance.

Last updated: September 2026