Skip to content

Self-Hosting Guide

Self-hosting runs the HumanAuth platform — the authorization server that routes requests to humans, verifies device co-signatures, and mints receipts — inside your own Cloudflare account. Approval data, tenant signing keys, and audit history never leave infrastructure you control, and receipts stay verifiable with the standard open verifier. Self-hosting is licensed and supported under an Enterprise agreement.

One Worker plus the managed primitives it binds:

PieceCloudflare primitivePurpose
Platform APIWorker (packages/platform)/v1/* — authorize, respond, receipts, JWKS, webhooks
StateD1 databaseTenants, humans, devices, requests, receipts, signing keys, audit
Webhook deliveryQueue + dead-letter queueAsync HMAC-signed event delivery with retries
Brand assetsR2 bucketTenant logo uploads
Rate limitingWorkers rate-limit bindingsPer-tenant authorize / per-human respond ceilings
SweepsCron trigger (1/min)Expiry flips, stranded webhook redelivery
Dashboard (optional)Pages (packages/dashboard)Admin UI — point it at your Worker

Not in this list, deliberately: the verifier and replay store, which run at your resource server in every deployment model, and the approver apps, which are distributed builds (see Limitations).

The verifier and replay store when self-hosting

Section titled “The verifier and replay store when self-hosting”

Two facts evaluators usually come here for:

The replay store doesn’t move — it was never ours to host. Self-hosting relocates the platform (issuance) into your Cloudflare account; the replay store always lives with your resource server, in hosted and self-hosted deployments alike. Set it up exactly as the Verifier SDK guide describes — the Redis/Postgres/SQLite/Workers KV adapters, the auto-provisioned schema, and the operations notes on pruning, fail-closed availability, and failover all apply unchanged.

The verifier needs exactly two settings: your JWKS and your issuer.

const verifier = new HumanAuthVerifier({
audience: "tn_your_tenant",
issuer: "https://approvals.example.com", // = HA_ISSUER below
jwksUri: "https://approvals.example.com/.well-known/jwks.json", // your platform Worker
replayStore: new RedisReplayStore(redis), // yours either way
});

Air-gapped enforcement can pass an inline jwks snapshot instead of jwksUri. Cross-acceptance between deployments is closed twice over: signatures only verify against your JWKS (every kid is tenant-scoped), and the pinned issuer rejects receipts minted by any other deployment.

  • A Cloudflare account on Workers Paid (Queues requires it), with wrangler logged in (npx wrangler login).
  • Node 20+ and pnpm; openssl on PATH for key generation.
  • Pick your auth mode — the platform refuses to boot without an explicit choice:
    • operator (the bare tier — start here). No identity provider anywhere. Approvers enroll via single-use invite codes you mint from the CLI, and their device keys are the credential from then on. You are the identity authority: handing the code to the right person is the identity ceremony, and offboarding is deactivating the roster row.
    • workos — bring a WorkOS AuthKit application; dashboard sign-in and IdP-based enrollment authenticate through it (this is how the hosted platform runs).
  • The platform source, under your Enterprise agreement.

The reference implementation of everything below is packages/platform/scripts/provision-prod.sh — a provisioning script you can read top to bottom and adapt. The manual sequence:

1. Point wrangler.toml at your account. In packages/platform/wrangler.toml, replace the production [[routes]] block with your own domain and zone (or set workers_dev = true while evaluating), and prepare to paste your D1 id in the next step. The [env.staging] block shows the same pattern for a pre-production environment — recommended, so migrations and key rotation get a rehearsal target.

2. Create the database and apply migrations.

Terminal window
cd packages/platform
npx wrangler d1 create humanauth-prod # paste the id into wrangler.toml
npx wrangler d1 migrations apply humanauth-prod --remote

Migrations are ordered SQL files in migrations/; migrations apply is also the upgrade path later.

3. Create the queues and buckets.

Terminal window
npx wrangler queues create humanauth-webhook-deliveries
npx wrangler queues create humanauth-webhook-deliveries-dlq
npx wrangler r2 bucket create humanauth-brand-assets
npx wrangler r2 bucket create humanauth-brand-assets-preview

4. Generate and set the secrets. Three platform keys — plus the WorkOS trio only if you chose workos mode:

Terminal window
openssl rand -hex 32 | npx wrangler secret put SIGNING_PRIVATE_KEY
openssl genpkey -algorithm ed25519 | npx wrangler secret put HA_SIGNING_KEY_PEM
openssl rand -hex 32 | npx wrangler secret put HA_KEK_V1_HEX
npx wrangler secret put WORKOS_CLIENT_ID # from your WorkOS dashboard
npx wrangler secret put WORKOS_API_KEY
npx wrangler secret put WORKOS_WEBHOOK_SECRET

HA_KEK_V1_HEX is the key-encryption key that activates per-tenant signing keys (each tenant’s receipts sign under its own kid, stored AES-256-GCM-encrypted in D1). HA_SIGNING_KEY_PEM + the HA_SIGNING_KEY_KID var are the platform-wide fallback. Keep offline copies of all three in your secrets manager — rotation runbook: packages/platform/docs/operations/key-rotation.md.

5. Declare your mode and issuer. In wrangler.toml [vars]:

AUTH_MODE = "operator" # or "workos" — explicit, always; partial config refuses to boot
HA_ISSUER = "https://approvals.example.com"

Every receipt your deployment mints carries this as its iss claim; every verifier you run pins the same value in its issuer option. Left unset, receipts claim the hosted platform’s https://api.humanauth.ai — functional, but wrong provenance for a self-hosted deployment; set it before minting anything you intend to keep.

6. Deploy and smoke-test.

Terminal window
npx wrangler deploy
curl https://approvals.example.com/health # {"status":"ok",...}
curl https://approvals.example.com/.well-known/jwks.json

Operator-mode onboarding — the whole loop

Section titled “Operator-mode onboarding — the whole loop”

With AUTH_MODE=operator deployed, enrolling your first approver is three commands and a scan:

Terminal window
humanauth invite create cfo@example.com --qr # mints a single-use code, renders the connect QR

The approver scans it from the HumanAuth app’s Connect to your organization first-run (shipping in the next app release; until then, any client implementing the documented enrollment call — POST /v1/invites/redeem with a freshly generated device key — enrolls the same way). Redemption atomically claims the code, creates the human and roster entry, registers the device public key, and returns a device session. From there the standard loop applies unchanged:

Terminal window
humanauth test payments:transfer # fires a real approval request
humanauth verify <jws> --issuer https://approvals.example.com

No IdP, no HumanAuth account, no third-party cloud — approve with a biometric, verify offline. Invites are audited (invite.created / invite.redeemed / invite.revoked), listable (humanauth invite list), and revocable before redemption.

(workos mode) Deploy the dashboard (packages/dashboard, an Astro app built for Cloudflare Pages) and give it two things: your WorkOS AuthKit settings (WORKOS_CLIENT_ID, WORKOS_API_KEY, WORKOS_REDIRECT_URI) and your platform URL (PLATFORM_BASE_URL). Sign in, and the dashboard walks you through self-serve tenant creation (POST /v1/tenants) — one call creates the tenant, your owner roster entry, and the first API key, shown in full exactly once. From there: register approver devices from the apps, add humans and groups, and run the 5-minute quickstart against your own URL.

Every client takes a base-URL override; the hosted URL is only a default:

PieceSetting
@humanauth/sdknew HumanAuth({ apiKey, baseUrl: "https://approvals.example.com" })
@humanauth/verifierissuer + jwksUri (see above)
@humanauth/clibaseUrl in the CLI config file
@humanauth/mcpbase-URL environment override — see its README
DashboardPLATFORM_BASE_URL on the Pages project
WebhooksYour subscriptions, delivered from your Worker via your queue

What “highly available” means here, honestly stated:

  • Compute has no single home. The Worker runs in every Cloudflare data center; there is no region to fail over from, no instance count to manage, no server you patch.
  • State is the one single-writer core. D1 is a managed, durable, single-primary database — not multi-region active-active. It is the availability bottleneck of the control plane: if D1 is unreachable, new approvals and receipt minting pause. Point-in-time recovery (Time Travel) plus your own wrangler d1 export snapshots cover durability.
  • Enforcement survives platform outages by design. Verification is offline against cached JWKS — a platform outage can never make an already-minted receipt unverifiable, never blocks your resource servers, and never fails open. The blast radius of total platform downtime is precisely: new approvals stall until it returns (and a request whose TTL lapses during an outage is simply re-asked).
  • Webhooks are at-least-once. Deliveries flow through a Cloudflare Queue with five platform retries, app-level retry accounting in D1 with backoff, a per-minute sweep for stranded deliveries, and a dead-letter queue whose depth is itself a monitoring signal.

The design bias, consistent with the rest of the system: the moment that must never break (enforcement) depends on nothing here; the moment that can tolerate a pause (asking a human) degrades to waiting, never to unproven actions.

  • Backups: scheduled wrangler d1 export to your own storage, on top of D1 Time Travel. The secrets (HA_SIGNING_KEY_PEM, HA_KEK_V*_HEX, SIGNING_PRIVATE_KEY) live only in Workers Secrets — keep the offline copies from step 4 current, or minted receipts outlive your ability to rotate gracefully.
  • Upgrades: pull the release, npx wrangler d1 migrations apply … --remote, npx wrangler deploy — rehearsed against the staging environment first. Migrations are additive-only in practice; releases note any exceptions.
  • The Worker terminates TLS at Cloudflare; there is no origin server to harden.
  • /.well-known/jwks.json is public by design — it’s the verification root, not a secret.
  • API keys can create requests but can never approve them; approvals require registered devices with hardware-held keys, same as hosted.
  • KEK and signing-key rotation follow docs/operations/key-rotation.md (versioned KEKs, re-encrypt, retire).
  • The replay stores at your resource servers are part of your enforcement security posture — see operations.
  • Approver apps target the hosted API. The store builds carry two hosted bindings: the platform base URL (Mac: compile-time in Config.swift; iOS: build-time EXPO_PUBLIC_PLATFORM_URL) and the WorkOS AuthKit client the sign-in flow authenticates against — a self-hosted platform runs your WorkOS environment, so the hosted apps’ sign-in tokens would be rejected even if the URL matched. No custom implementation is needed, though: the same app codebases rebuild against your URL + your WorkOS client id, distributed through your Apple Business Manager or TestFlight — an Enterprise onboarding item today, with a first-run “connect to your organization” step on the roadmap so one store build serves every deployment. The cryptography is already deployment-agnostic: device keys are generated on-device and registered with whichever platform they pair to.
  • Push delivery is on the roadmap; approver apps currently learn of pending requests by polling, which self-hosting does not change.
  • One platform, many tenants: a self-hosted deployment is multi-tenant exactly like the hosted one; run one deployment per isolation domain, not per team.
  • Architecture — the three-party topology self-hosting relocates one corner of
  • Verifier SDK — enforcement setup, unchanged by self-hosting
  • Security Model — what the receipts prove, regardless of who hosts