Skip to content
Guide · Tutorial

Hardware-ID tracking

The simplest thing RootHerald gives you is a stable, hardware-rooted device identity— practically unforgeable at commercial cost. This tutorial wires enrollment end to end, binds that identity to your own user accounts, and uses it for hardware banning and Sybil defense. You barely look at the attestation verdict here; the identity is the product.

What the identity is

Every device resolves to a ueid: a UUID derived (v5) from the TPM’s endorsement-key fingerprint. It is the same value for the same physical chip across OS reinstalls, account changes, and attestation-key rotation, and a different chip can’t produce it. That stability is exactly what makes it a good account anchor. You receive it in two places, the enroll relay response (deviceId) and every verdict (device.ueid), and they’re the same value.

1. Set up the server client

Your backend holds the rh_sk_ secret; the browser never sees it. Everything below runs server-side.

rh.tsts
import { RootHerald } from "@rootherald/node";

export const rh = new RootHerald({ secretKey: process.env.RH_SECRET_KEY! });
// RH_SECRET_KEY looks like "rh_sk_...". A publishable rh_pk_ key is rejected.

2. Wire the keyless enroll relay (and capture the ueid)

The page-side SDK runs the local TPM work and hands you two opaque blobs to relay. Your two endpoints just forward them through the server client, and this is where you bind the returned deviceId to the signed-in user.

server: enroll relayts
// Called by the browser SDK's enroll(relay). requireLogin is YOUR auth.
app.post("/rh/enroll-relay", requireLogin, async (req, res) => {
  const result = await rh.relayEnroll(req.body.enrollRequestBlob);
  // result.deviceId is the stable hardware identity (the ueid).
  // Bind it to the current account in YOUR database:
  await db.deviceLinks.upsert({
    ueid: result.deviceId,
    userId: req.user.id,
    firstSeenAt: new Date(),
  });
  res.json(result); // { deviceId, challenge?, alreadyEnrolled }
});

app.post("/rh/activate-relay", requireLogin, async (req, res) => {
  const result = await rh.relayActivate(req.body.activationBlob);
  res.json(result); // { deviceId }
});
browser: drive enrollmentts
import { enroll } from "@rootherald/browser";

const relay = {
  enroll: (blob) =>
    fetch("/rh/enroll-relay", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ enrollRequestBlob: blob }),
    }).then((r) => r.json()),
  activate: (blob) =>
    fetch("/rh/activate-relay", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ activationBlob: blob }),
    }).then((r) => r.json()),
};

const { deviceId } = await enroll(relay); // one UAC, first time only
// deviceId is now bound to this user on your server.
Enrollment is the one-time step

enroll()shows a single “establish hardware key” prompt the first time on a device; it’s silent forever after. See Re-attest without a prompt and Device enrollment for the ceremony details — this tutorial focuses on what you do with the identity.

3. Your binding table

One table maps hardware to accounts. Because a ueid is stable, this is a durable, chip-level join. Model it as many-to-many if you allow a device to sign in to more than one account, or add a unique constraint if you want one-account-per-device.

schema (illustrative)text
CREATE TABLE device_links (
  ueid        uuid NOT NULL,           -- the hardware identity from RootHerald
  user_id     uuid NOT NULL REFERENCES users(id),
  first_seen  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (ueid, user_id)
);
CREATE INDEX ON device_links (ueid);     -- "which accounts share this chip?"
CREATE INDEX ON device_links (user_id);  -- "which devices does this user use?"

4. Recognize the device on later requests

On any request where you want to know the hardware, run a fresh attestation and read theueidoff the verdict. You don’t need to inspect the trust verdict for this use case — you only need the identity, and the fact that it came from a live quote (so it can’t be replayed or spoofed).

server: identify the devicets
// 1) issue a challenge, 2) client collects evidence, 3) verify -> ueid
const { challengeId, nonce } = await rh.issueChallenge();
// ... hand nonce to the page, it calls attest(nonce), posts evidence back ...
const verdict = await rh.verify(evidence, { challengeId });

const ueid = verdict.device.ueid; // the same chip-stable id you bound at enroll
const accounts = await db.deviceLinks.findUserIdsByUeid(ueid);

5. What you can do with it

Now that accounts and hardware are joined, a few high-value controls fall out.

Hardware banning (account-abuse defense)

Ban the chip, not just the account: a banned user can’t escape by making a new account on the same machine. Two layers, use either or both:

server: your own ban checkts
// Ban: record the ueid (e.g. after fraud / ToS abuse).
await db.bannedDevices.upsert({ ueid, reason, bannedAt: new Date() });

// Enforce: on every attested request, refuse a banned chip — and every
// account that shares it.
const verdict = await rh.verify(evidence, { challengeId });
if (await db.bannedDevices.has(verdict.device.ueid)) {
  return res.status(403).json({ error: "device_banned" });
}
Or ban at the source (RootHerald-side)

You can also add the ueidto RootHerald’s tenant ban list via the admin API; a banned device then fails verify at the source (a contraindicated verdict with reason device_banned), so you don’t have to check locally:

admin APIhttp
POST /api/v1/admin/ban-list      { "deviceId": "<ueid>", "reason": "abuse" }
DELETE /api/v1/admin/ban-list/<ueid>   # unban

The ban list is Owner-scoped (dashboard / admin credentials), so it’s the right layer for a human trust-and-safety action; the local check above is the right layer for automated, high-volume enforcement.

Sybil / multi-account limits

One real TPM = one real machine. Cap how many accounts a single chip can back, and trial abuse / content farms have to buy silicon to scale.

server: account-per-device capts
const MAX_ACCOUNTS_PER_DEVICE = 3;

const count = await db.deviceLinks.countUsersByUeid(verdict.device.ueid);
if (count >= MAX_ACCOUNTS_PER_DEVICE) {
  return res.status(403).json({ error: "device_account_limit" });
}

Device-bound sign-in

Pin an account to its known devices. A login carrying a ueidthat isn’t in device_linksfor that user is a new device, so challenge it (email code, step-up) before you add the binding. A stolen session replayed on other hardware simply doesn’t match.

The ueid is an identifier, not a secret

Treat ueidlike a stable primary key, not a bearer token. It’s meaningful only because it arrived on a fresh, live quote your server verified. Always bind and enforce off a verify() result, never off a value a client asserts on its own.