Skip to content
Integrate · BrowserIn development

Verify a device from a web app

The full client + server flow for a web app: your backend issues a challenge, the page collects a fresh TPM quote through the Root Herald extension, and your backend appraises the evidence. The client holds no key — your rh_sk_ secret never leaves your server.

What ships today, and what to try instead

The server half — @rootherald/node — is available on npm. The client half is in preview: the @rootherald/browser package is in development and not yet published to npm, and the Root Herald browser extension is not yet in the Chrome/Edge/Firefox stores. Do not expect npm install @rootherald/browser to resolve yet. The full chain works today, on real hardware — try it live at /try. The steps below are the integration you wire once both pieces publish; the API names are the real, shipping surface.

1

[server] Set up the server SDK

What to do → create a project in the dashboard, copy its rh_sk_… secret key, and install the server SDK (this one is live).

terminalbash
npm install @rootherald/node

What you get → a RootHerald client your backend uses to open and close every attestation. Why → the secret and the appraisal live only on your server; the browser is untrusted and keyless.

server setup (Node)ts
import { RootHerald } from "@rootherald/node";

const rh = new RootHerald({ secretKey: process.env.RH_SECRET_KEY! }); // rh_sk_…
2

[server] Issue a challenge and relay the nonce

What to do → expose an endpoint that mints a single-use challenge and returns it to your page.

api/challenge (Node)ts
app.post("/api/challenge", async (_req, res) => {
  const { challengeId, nonce } = await rh.issueChallenge();
  // Relay both to the page. Keep challengeId server-side too if you prefer;
  // you need it again at verify time.
  res.json({ challengeId, nonce });
});

What you get → { challengeId, nonce }. Why → the client will quote the TPM over nonce, binding the evidence to this exact request so it can't be replayed.

3

[client install] The user needs the extension + native host

What to do → the end user installs the Root Herald browser extension and the native messaging host once. The extension bridges your page to the host; the host drives the local TPM. Why →a web page can't reach the TPM directly, so the extension + host are the trusted local collector.

How the bridge works, honestly

There isn't a standalone “how the extension and host work” page yet. The closest references that exist today: the browser SDK page (the page ↔ extension ↔ host bridge and the two client verbs), the device-enrollment guide (trust-on-first-use and the first-attestation bootstrap), and the working chain you can drive end to end at /try. Until the extension is in the stores, treat /try as the reference install.

4

[client] Collect evidence in the page

What to do → fetch a challenge from your backend, then call attest(nonce). It returns the opaque evidence blob — the page holds no Root Herald key and gets no verdict.

page.ts (@rootherald/browser)ts
import {
  attest,
  ExtensionMissingError,
  HostMissingError,
  NotEnrolledError,
  TimeoutError,
} from "@rootherald/browser";

async function verifyThisDevice() {
  // Beat 1+2: your backend issued the challenge.
  const { challengeId, nonce } = await fetch("/api/challenge", {
    method: "POST",
  }).then((r) => r.json());

  let evidence;
  try {
    // Beat 3: collect a fresh TPM quote over the nonce. Opaque blob, no verdict.
    evidence = await attest(nonce);
  } catch (err) {
    if (err instanceof ExtensionMissingError) {
      return promptInstallExtension(); // route the user to install the extension
    }
    if (err instanceof HostMissingError) {
      return promptInstallHost();       // extension present, native host missing
    }
    if (err instanceof NotEnrolledError) {
      // First time on this device — bootstrap the key, then retry attest().
      return runEnrollThenRetry(nonce);
    }
    if (err instanceof TimeoutError) {
      return showRetry();               // a TPM quote can be slow; let them retry
    }
    throw err;
  }

  // Beat 4: hand { challengeId, evidence } to YOUR backend (next step).
  return fetch("/api/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ challengeId, evidence }),
  }).then((r) => r.json());
}

What you get → an opaque evidence blob (plus typed errors that map to concrete fixes). Why → each error routes the user to the right recovery — install the extension, install the host, enroll, or retry — instead of a dead end.

5

[wire] Post the blob to your backend

What to do → POST { challengeId, evidence } to your own endpoint (done inline above). Why → only your backend carries rh_sk_, so only it can appraise the blob.

6

[server] Appraise the evidence and act

What to do → call rh.verify(evidence, { challengeId, policy }) and branch on the verdict.

api/verify (Node)ts
import { QuotaExceededError } from "@rootherald/node";

app.post("/api/verify", async (req, res) => {
  const { challengeId, evidence } = req.body;

  let verdict;
  try {
    verdict = await rh.verify(evidence, {
      challengeId,
      policy: "rootherald:builtin:strict-hardware",
    });
  } catch (err) {
    if (err instanceof QuotaExceededError) return res.status(429).end();
    throw err; // InvalidSecretKeyError, ChallengeError, InvalidEvidenceError…
  }

  if (verdict.device.verdict !== "pass") {
    return res.status(403).json({ error: "device_check_failed" });
  }

  // Stable, tenant-scoped, no-PII device pseudonym — the enforcement handle.
  const deviceId = verdict.device.ueid;
  res.json({ ok: true, deviceId });
});

What you get → a verdict with device.verdict, device.ueid, device.earStatus, and acr. Why → a failing device returns a verdict (not an exception), so you decide the response; only protocol/auth/quota problems throw.

Test it, then go deeper

Drive the whole chain — extension → host → TPM → your backend → verdict — against a real TPM at /try. That is where “it works” is anchored while the client packages finish publishing.