Skip to content
Transport modes · Customer proxy

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

data flowtext
[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 verdict

Your 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/challenge and /api/v1/attestations/verify — to api.rootherald.io. If you also opt into portable tokens (returnToken) and verify them yourself, forward /.well-known/jwks.json as well.
  • Pass through the Authorization header (your rh_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

Code drops Wave 2Wire shape is finalized; reference code lands with the Wave 2 SDK release.

The shapes look like this:

Express (Node)ts
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);
ASP.NET Core (YARP)csharp
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();
    });
});
Cloudflare Workerts
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,
    });
  },
};
Latency budget

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.