Inboarddocs

Verifying signed snippets

Independently verify the Ed25519 signature on a published install template — re-canonicalize, re-hash, and check the curve against Inboard's public key.

Every published install template version is signed with Ed25519. The widget shows the signature so installers can trust the snippet they're pasting came from you unaltered, and anyone — your own security team, a cautious installer, a procurement reviewer — can verify it independently without trusting Inboard's UI.

You will learn

  • Where to fetch the signature payload and the public key.
  • Exactly how the signature is constructed, so you can recompute it byte-for-byte.
  • A complete, runnable verification script in Node.js and in the browser.

When you'd use this

  • Building a "verified ✓" badge into your own onboarding page.
  • A customer's security team wants to audit a snippet before approving the install.
  • Pinning a known key_id so a future key rotation can't silently change what you trust.

If you just want installers to see that a snippet is signed, the widget already does that for you — this page is for verifying it yourself, off the widget.

The two endpoints

The signature endpoint is versioned under /v1; the key endpoints live at the well-known root (not under /v1) so those URLs stay stable across API versions.

GET /v1/versions/:id/signature

Returns the canonical content that was signed plus the signature row. :id is the published version id (setup_version_id).

{
  "setup_version_id": "ver_abc123",
  "algorithm": "ed25519",
  "key_id": "ik-2026-05",
  "content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "signature": "0aVf...base64...==",
  "signed_at": "2026-05-12T09:41:03.000Z",
  "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
  "content": {
    "schema_version": 1,
    "setup_version_id": "ver_abc123",
    "install_template_id": "tpl_abc123",
    "version": 7,
    "status": "published",
    "published_at": "2026-05-12T09:40:58.000Z",
    "allowed_scopes": ["site", "page"],
    "default_scope": "site",
    "changelog": "Add DNS verification step",
    "requirements": [
      {
        "id": "req_001",
        "order": 0,
        "requirement_type": "code_snippet",
        "title": "Add the Acme script",
        "optional": false,
        "platform_ids": ["wordpress"],
        "installation_scopes": ["site"],
        "config": { "...": "..." },
        "verification": { "...": "..." }
      }
    ]
  }
}

public_key_pem is populated only when the version was signed by the currently active key. After a key rotation it comes back null for older versions — fetch the matching key from the catalog below using key_id. Cached for 5 minutes; unpublished or unknown ids return 404.

GET /.well-known/inboard-signing-key.pem

The currently active public key, as an SPKI PEM. A JSON form is at /.well-known/inboard-signing-key.json:

{ "key_id": "ik-2026-05", "algorithm": "ed25519", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n" }

For historical signatures, the full catalog (active and revoked keys) is at /.well-known/inboard-signing-keys.json. Always match by key_id, and treat any signature whose key_id is marked revoked as untrusted regardless of whether the curve checks out.

Looking up by content hash

If you already have a snippet's content_hash (the widget's "verified" badge links to it), the public audit endpoint resolves it without a version id:

GET /v1/audit/snippet/:sha256

This is what the public audit page at inboard.dev/audit/snippet/<hash> calls. Use the versions/:id/signature endpoint when you want the full canonical payload to replay; use the audit endpoint when all you have is the hash from a rendered snippet.

How the signature is built

This is the part to get exactly right — a naïve "verify the JSON against the signature" will fail. The signing pipeline is:

  1. Canonicalize the content object to JSON with every object key sorted lexicographically at every level, no insignificant whitespace, Dates as ISO-8601 strings. (Inboard's content is already emitted in this shape, but you must re-serialize it canonically yourself — don't trust the byte order of the response body.)
  2. Hash the canonical UTF-8 bytes with SHA-256 and take the lowercase hex digest. This string is content_hash.
  3. Sign the ASCII bytes of that hex digest string with Ed25519 — not the canonical JSON bytes, and not the raw 32 hash bytes. The hex string itself is the signed message.
  4. The signature is base64-encoded into the signature field.

So verification is: re-canonicalize → SHA-256 hex → confirm it equals content_hash → Ed25519-verify the signature over the ASCII bytes of content_hash using the public key.

The double indirection (signing the hex digest rather than the payload) is deliberate: it keeps the signed message a fixed 64-byte ASCII string regardless of payload size, and lets the same content_hash feed the public audit-log hash chain. Match it exactly or verification fails.

Canonical JSON rules

  • Sort object keys lexicographically, recursively.
  • No spaces or newlines between tokens (JSON.stringify of an already key-sorted structure).
  • null and absent/undefined values both serialize to null.
  • Numbers must be finite; arrays keep their order (Inboard pre-sorts arrays like allowed_scopes and platform_ids before signing, so don't re-sort array values).

Verify in Node.js

import { createHash, createPublicKey, verify } from "node:crypto";

// Recursively sort object keys → deterministic byte sequence.
function canonical(value) {
  if (value === null || value === undefined) return null;
  if (Array.isArray(value)) return value.map(canonical);
  if (value instanceof Date) return value.toISOString();
  if (typeof value === "object") {
    const out = {};
    for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
    return out;
  }
  return value;
}

async function verifySnippet(versionId) {
  const res = await fetch(`https://api.inboard.dev/v1/versions/${versionId}/signature`);
  if (!res.ok) throw new Error(`signature fetch failed: ${res.status}`);
  const sig = await res.json();

  // 1. Re-canonicalize the content and SHA-256 it to lowercase hex.
  const canonicalJson = JSON.stringify(canonical(sig.content));
  const contentHash = createHash("sha256").update(canonicalJson, "utf8").digest("hex");
  if (contentHash !== sig.content_hash) {
    throw new Error("content hash mismatch — payload does not match the signed hash");
  }

  // 2. Resolve the public key (inline when active, else from the catalog by key_id).
  let pem = sig.public_key_pem;
  if (!pem) {
    const cat = await fetch("https://api.inboard.dev/.well-known/inboard-signing-keys.json").then((r) => r.json());
    const match = cat.keys.find((k) => k.key_id === sig.key_id);
    if (!match) throw new Error(`unknown key_id ${sig.key_id}`);
    if (match.status === "revoked") throw new Error(`key ${sig.key_id} is revoked`);
    pem = match.public_key_pem;
  }
  const publicKey = createPublicKey({ key: pem, format: "pem" });

  // 3. Ed25519-verify the signature over the ASCII bytes of the hex digest.
  const ok = verify(
    null,
    Buffer.from(sig.content_hash, "utf8"),
    publicKey,
    Buffer.from(sig.signature, "base64"),
  );
  if (!ok) throw new Error("signature invalid");
  return { keyId: sig.key_id, version: sig.content.version };
}

Verify in the browser (WebCrypto)

The same logic with SubtleCrypto. You'll need the public key as raw SPKI bytes; the snippet below strips the PEM armour.

function canonical(value) {
  if (value === null || value === undefined) return null;
  if (Array.isArray(value)) return value.map(canonical);
  if (typeof value === "object") {
    const out = {};
    for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
    return out;
  }
  return value;
}

function pemToBytes(pem) {
  const b64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}

async function verifySnippet(versionId) {
  const sig = await fetch(`https://api.inboard.dev/v1/versions/${versionId}/signature`).then((r) => r.json());

  // 1. Re-canonicalize and SHA-256 → lowercase hex.
  const canonicalBytes = new TextEncoder().encode(JSON.stringify(canonical(sig.content)));
  const digest = await crypto.subtle.digest("SHA-256", canonicalBytes);
  const contentHash = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
  if (contentHash !== sig.content_hash) throw new Error("content hash mismatch");

  // 2. Import the Ed25519 public key (inline or from the catalog).
  const key = await crypto.subtle.importKey(
    "spki",
    pemToBytes(sig.public_key_pem),
    { name: "Ed25519" },
    false,
    ["verify"],
  );

  // 3. Verify over the ASCII bytes of the hex digest.
  const ok = await crypto.subtle.verify(
    { name: "Ed25519" },
    key,
    Uint8Array.from(atob(sig.signature), (c) => c.charCodeAt(0)),
    new TextEncoder().encode(sig.content_hash),
  );
  if (!ok) throw new Error("signature invalid");
  return sig.key_id;
}

Ed25519 in WebCrypto is available in current Chrome, Firefox, and Safari; in older runtimes fall back to a library like @noble/curves.

Pinning a key

If you want to fail closed when Inboard rotates keys unexpectedly, pin the key_id you trust and reject anything else:

const TRUSTED_KEY_ID = "ik-2026-05";
if (sig.key_id !== TRUSTED_KEY_ID) throw new Error("unexpected signing key");

Rotations are announced ahead of time, and old keys stay published so historical signatures keep verifying — so pinning is safe as long as you update the pin when you adopt a new key.

Troubleshooting

SymptomCause
Hash matches but signature failsYou signed/verified over the canonical JSON or the raw 32-byte hash. Verify over the ASCII bytes of the hex content_hash.
Hash mismatchKeys weren't sorted recursively, or you hashed the raw response body instead of re-canonicalizing.
public_key_pem is nullThe version was signed by a non-active key. Fetch the key from /.well-known/inboard-signing-keys.json by key_id.
404 on the signature endpointThe version is unpublished or the id is wrong — only published versions are signed.
503 from a well-known key endpointThe environment has no signing key configured (local/dev). Production and staging always have one.
  • Security — the overall trust model: embed keys, secret variables, CSP.
  • API reference — the rest of the public REST surface.
  • Account & security — the customer-facing view of snippet signing.

On this page