BoltHub logoBoltHub
Guides

Origin Protection

How bolthub authenticates proxied requests to your origin, and how to verify them — for both greenfield APIs and existing authenticated services.

When a paying caller hits your gateway URL, bolthub proxies the request to your origin (the URL you registered when creating the endpoint). Your origin needs a way to tell legitimate proxied traffic from bolthub apart from anyone hitting your URL directly. This page covers exactly what bolthub sends and how to verify it — whether you're building a brand-new API for publication, or wrapping an existing service.

What bolthub sends on every proxied request

Every request the gateway forwards to your origin carries these headers:

HeaderValuePurpose
X-Gateway-SignatureHMAC-SHA256 hex digest of the requestCryptographic proof that bolthub originated this request
X-Gateway-TimestampUnix milliseconds when the signature was computedOrigin should reject anything older than 30 seconds (replay window)
X-Gateway-NonceRandom UUID per requestLets your origin de-duplicate retried requests if needed
X-Gateway-SecretThe tenant's static gateway secretSecondary defense — a static shared secret. Useful as a cheap pre-filter
X-Request-IdUUID for correlationMatch request to bolthub-side traces. Not auth-relevant

The signature is computed over a canonical payload:

METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY

Where:

  • METHOD is the HTTP verb (GET, POST, etc.).
  • PATH is the gateway path on your gateway domain (e.g. /v1/weather).
  • TIMESTAMP and NONCE are the literal header values.
  • BODY is the raw request body bytes, decoded as UTF-8. Empty string for GET/HEAD.

Both fields are dollar-sign-free literals — the \n is a literal LF character, not the escape sequence.

Choose your path

If you're building a brand new API specifically for bolthub publication, jump to Greenfield API: bolthub is my only caller.

If you have an existing authenticated API and you're putting bolthub in front of it, jump to Wrapping an existing authenticated API.

The mechanics are the same; the recommendations differ.

Greenfield API: bolthub is my only caller

This is the simpler case. If bolthub is the only intended caller, HMAC signature verification is sufficient on its own — you don't need bearer tokens, API keys, or session management. Reject every request that fails signature verification, and you have a complete authentication layer.

Minimum viable check

A request is legitimate if and only if all of these are true:

  1. X-Gateway-Signature, X-Gateway-Timestamp, and X-Gateway-Nonce are all present.
  2. now() - X-Gateway-Timestamp is between 0 and 30,000 ms.
  3. The HMAC-SHA256 of the canonical payload, keyed by your HMAC secret, matches X-Gateway-Signature byte-for-byte (use a timing-safe compare).

If any of those fail, return 403. That's the entire authentication story.

Next.js (App Router) — Route Handler

This is the most common greenfield setup. Verify in each handler so you have raw-body access without middleware wiring.

// app/api/your-endpoint/route.ts
import { NextResponse } from "next/server";

const SECRET = process.env.HMAC_SECRET!;
const MAX_AGE_MS = 30_000;

async function verify(req: Request, body: string): Promise<boolean> {
  const sig = req.headers.get("x-gateway-signature");
  const ts  = req.headers.get("x-gateway-timestamp");
  const nc  = req.headers.get("x-gateway-nonce");
  if (!sig || !ts || !nc) return false;

  const age = Date.now() - Number(ts);
  if (age > MAX_AGE_MS || age < 0) return false;

  const url = new URL(req.url);
  const payload = `${req.method}\n${url.pathname}\n${ts}\n${nc}\n${body}`;
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(SECRET),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
  const expected = [...new Uint8Array(mac)]
    .map(b => b.toString(16).padStart(2, "0")).join("");
  return sig === expected;
}

export async function POST(req: Request) {
  // Read body as raw text FIRST so the signature still matches.
  // Calling req.json() before verify() consumes the stream and
  // breaks the signature check.
  const raw = await req.text();
  if (!(await verify(req, raw))) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const data = JSON.parse(raw || "{}");
  return NextResponse.json({ ok: true, echo: data });
}

Other Node.js / Bun frameworks

For Express, Fastify, Hono, or anything Node-flavored, install @bolthub/verify:

npm install @bolthub/verify
import { verifyGatewaySignature } from "@bolthub/verify";

const HMAC_SECRETS = [process.env.GATEWAY_HMAC_SECRET!];

function handle(req: { method: string; path: string; headers: Record<string, string>; body: string }) {
  const result = verifyGatewaySignature(
    { method: req.method, path: req.path, headers: req.headers, body: req.body },
    { secrets: HMAC_SECRETS, maxAgeMs: 30_000 },
  );
  if (!result.valid) {
    return new Response(JSON.stringify({ error: result.error }), { status: 403 });
  }
  // ... your handler
}

For Express specifically, there's a drop-in middleware:

import { expressHmacMiddleware } from "@bolthub/verify";

// Capture raw body BEFORE express.json() parses it.
app.use(expressHmacMiddleware({ secrets: HMAC_SECRETS, maxAgeMs: 30_000 }));

Python

pip install bolthub-verify
import os
from bolthub_verify import verify_gateway_signature

HMAC_SECRETS = [os.environ["GATEWAY_HMAC_SECRET"]]

def handle(request):
    body = request.body.decode("utf-8") if isinstance(request.body, bytes) else (request.body or "")
    result = verify_gateway_signature(
        method=request.method,
        path=request.path,
        signature=request.headers.get("X-Gateway-Signature"),
        timestamp=request.headers.get("X-Gateway-Timestamp"),
        nonce=request.headers.get("X-Gateway-Nonce"),
        body=body,
        secrets=HMAC_SECRETS,
        max_age_ms=30_000,
    )
    if not result.valid:
        return Response("Forbidden", status=403)
    # ... your handler

Drop-in middleware is included for Flask, Django, and FastAPI:

from bolthub_verify import flask_hmac_middleware, django_hmac_middleware, fastapi_hmac_middleware

Each handles raw-body capture and returns a 403 on failure.

Common pitfalls

  • Body parsing before verify: any framework that auto-parses JSON (Next.js Route Handlers via req.json(), Express via express.json(), FastAPI via Pydantic models) consumes the body stream. Always capture the raw bytes first, verify, then parse.
  • Path mismatch: the path in the canonical payload is your gateway path (e.g. /v1/weather), not necessarily the path on your origin. If you map /v1/weather on the gateway to /internal/weather/v1 on the origin, the signature was computed over /v1/weather. The SDKs handle this by reading from the request directly; if you implement manually, use the path on the incoming request.
  • String comparison without timing-safety: === in JS or == in Python can leak information about which byte differs. Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), MessageDigest.isEqual (Java).

Wrapping an existing authenticated API

If your API already has authentication (bearer tokens, API keys, OAuth), bolthub's headers are an additional layer, not a replacement. The simplest pattern:

1. Check X-Gateway-Signature (HMAC) — if valid, the request came from bolthub.
2. Trust it as a "paying caller" and proceed to your normal authorization logic.
3. If the signature is missing or invalid, reject (403) OR fall back to your
   regular auth flow, depending on whether direct calls are still allowed.

For a single API where bolthub is one of several entry points (e.g. you also have a web app calling the same endpoints with session cookies), the dual-auth pattern is straightforward:

async function handle(req: Request) {
  // Path 1: direct callers with a session / bearer token (your usual auth).
  const session = await getSessionFromRequest(req);
  if (session) return runWithUser(req, session.user);

  // Path 2: callers coming through bolthub.
  if (await verifyGatewaySignature(req)) {
    return runWithUser(req, ANONYMOUS_PAID_USER);
  }

  return new Response("Forbidden", { status: 403 });
}

The ANONYMOUS_PAID_USER placeholder is up to you. Bolthub does not send the end-caller's identity to your origin — paid requests are pseudonymous by design. If you need per-user features (favorites, history), expose them via your direct-auth path; the bolthub path is for stateless transactional access.

When to keep the static secret check

If you can't capture the raw request body (some legacy environments, or proxies that re-encode bodies), the HMAC path is unworkable. In that case, fall back to X-Gateway-Secret:

if (req.headers.get("x-gateway-secret") === process.env.GATEWAY_SECRET) {
  // proceed
}

The static-secret path is weaker than HMAC (no replay protection, the secret is sent on every request), but it's better than no check at all. Combine it with IP allowlisting (below) for defense in depth.

IP allowlisting (defense in depth)

If your origin lives behind a firewall, security group, or WAF, allowlist bolthub's egress IPs as a second layer:

  • Fly.io egress IPs: the bolthub gateway runs on Fly.io. Configure your firewall to only accept inbound traffic from Fly.io's published IP ranges.
  • AWS: security groups attached to the listener / load balancer.
  • GCP: VPC firewall rules.
  • Cloudflare: rule that requires the request to come from the Fly.io ASN.

This is recommended on top of signature verification, not as a replacement. Fly's IP ranges can change, and an IP allowlist alone wouldn't catch an attacker who managed to spoof the source IP.

Secret rotation

Rotate your secrets from the dashboard whenever you suspect compromise or on a scheduled cadence (e.g. quarterly).

When you rotate:

  1. The current secret becomes the previous secret and stays valid for 48 hours.
  2. A fresh secret is generated and used for all new requests.
  3. Your origin should accept either during the grace window.

Both @bolthub/verify and bolthub-verify accept a list of secrets and try each in turn with a timing-safe compare:

verifyGatewaySignature(req, {
  secrets: [
    process.env.GATEWAY_HMAC_SECRET!,           // new
    process.env.GATEWAY_HMAC_SECRET_PREVIOUS!,  // drop after the grace period
  ],
});
verify_gateway_signature(
    method=request.method, path=request.path,
    signature=signature, timestamp=timestamp, nonce=nonce, body=body,
    secrets=[
        os.environ["GATEWAY_HMAC_SECRET"],            # new
        os.environ["GATEWAY_HMAC_SECRET_PREVIOUS"],   # drop after the grace period
    ],
)

If you implement verification manually, do the same: try each secret in turn, fail closed if none match.

What bolthub does not send

A few things worth knowing the absence of:

  • No end-user identity. Paid callers are pseudonymous to your origin. If you need per-user features, add your own auth on top.
  • No client IP. Bolthub strips X-Forwarded-For, X-Real-IP, and similar headers before forwarding. This is by design — the gateway is the source of trust, not the underlying caller. If you need per-caller rate limiting, key on X-Request-Id and bolthub-side metrics, not client IP.
  • No bolthub session cookie. The dashboard's session is never sent to origin.
  • No client TLS material. Bolthub does not currently support mutual TLS to origin. If your security model requires mTLS, contact us.

Where the secrets live

  • Dashboard: Settings → Secrets shows the current HMAC and gateway secrets, lets you rotate them, and displays the previous values during the 48-hour grace window.
  • At endpoint creation: the same secrets appear on the "Protect your origin API" step of the create-endpoint wizard, with copy-to-clipboard buttons and the same code examples in your preferred language.

What's not in scope here