Run a reverse proxy on your own infrastructure
Maximum control. You run a thin reverse proxy in your VPC / Cloudflare account / Lambda@Edge that forwards your server's /api/v1/*Background-Check calls to Root Herald upstream, so every byte flows through infra you own. (In the default flow the user's device never calls Root Herald at all; it hands evidence to your backend, which is what makes these calls.)
Architecture
[device] ──evidence──► [your server] ──► [your proxy] ──► [api.rootherald.io]
dumb collector (you run) (you run) (we run)
no keys, no RH createChallenge /api/v1/* verifier + DB
contact + attest() forwarded up returns verdictYour proxy needs to:
- Terminate TLS for
auth.acme.comusing your cert (or your CDN's). - Forward all paths matching
/api/v1/*— which includes/api/v1/attestations/challengeand/api/v1/attestations/verify— toapi.rootherald.io. If you also opt into portable tokens (returnToken) and verify them yourself, forward/.well-known/jwks.jsonas well. - Pass through the
Authorizationheader (yourrh_sk_secret key). Because the call originates from your server, per-end-user rate limiting is driven by your own logic; there are no end-user IPs for Root Herald to see in this flow.
Reference implementations
The shapes look like this:
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const app = express();
app.use(
// /api/v1 covers challenge + verify; add jwks only if you opt into returnToken.
["/api/v1", "/.well-known/jwks.json"],
createProxyMiddleware({
target: "https://api.rootherald.io",
changeOrigin: true,
}),
);
app.listen(443);var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromMemory(
new[]
{
new RouteConfig
{
RouteId = "rh",
ClusterId = "rh-upstream",
Match = new RouteMatch { Path = "/api/v1/{**catch-all}" },
},
},
new[]
{
new ClusterConfig
{
ClusterId = "rh-upstream",
Destinations = new Dictionary<string, DestinationConfig>
{
["root"] = new() { Address = "https://api.rootherald.io" },
},
},
});
var app = builder.Build();
app.MapReverseProxy(opt =>
{
opt.Use(async (ctx, next) =>
{
ctx.Request.Headers["X-Root-Herald-Forwarded-For"]
= ctx.Connection.RemoteIpAddress?.ToString();
await next();
});
});export default {
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
if (!url.pathname.startsWith("/api/v1") &&
url.pathname !== "/.well-known/jwks.json") {
return new Response("Not found", { status: 404 });
}
const upstream = new URL(url.pathname + url.search, "https://api.rootherald.io");
const headers = new Headers(req.headers);
return fetch(upstream, {
method: req.method,
headers,
body: req.body,
});
},
};Adding a hop costs latency. The exact cost depends on where you run the proxy: Cloudflare Worker → +5-15ms p50; same-region VPC → +1-5ms; cross-region container → +30-80ms. Pick a colocation strategy that keeps the proxy in the same region as your users.
Health checks
We expose /health as a no-auth liveness probe (returns 200 with {"ok":true,"version":"…"}). Configure your proxy to do an active health check against this and fail fast if Root Herald is unreachable; the SDKs handle HTTP 502 / 503 from your proxy as a transient and retry.