Build an Approver
The official HumanAuth apps (Mac, iPhone) are the recommended approver surface — one audited key-ceremony implementation backing every receipt. But the wire protocol is open, and nothing stops you building your own approver: an internal ops tool, an embedded approve-button in your own app, a hardware token. This page is the complete contract. Byte layouts are normative in Protocol Internals; this page is the practical walkthrough.
What an approver is
Section titled “What an approver is”A device that (1) holds a signing keypair whose private key never leaves it, (2) shows a human exactly what they are approving, and (3) produces the device co-signature that ends up inside receipts. Everything a receipt claims about human consent rests on your implementation doing those three things honestly.
The bar an approver must clear (this is what “certified” will mean, and what the official apps do):
- Keys generated on-device, non-exportable — Secure Enclave / StrongBox / TPM where available; at minimum, OS-keystore-backed.
- Signing gated by a fresh biometric or local authentication at the moment of decision — report
assurancetruthfully (tap= presence only;biometric= Face ID / Touch ID class gate;hardware= hardware-backed key whose use required biometric release — surfaces aselevatedin receipts). - Display-before-sign integrity: sign precisely the request you rendered; never sign content the human did not see.
- One keypair per server — never reuse a key across deployments.
1 — Enroll
Section titled “1 — Enroll”Operator mode (self-hosted; no IdP): the admin hands your user an invite — as a harp://connect?v=1&url=…&code=… QR or a raw ha_inv_… code. Generate the keypair, then:
POST {url}/v1/invites/redeem{ "code": "ha_inv_…", "device": { "pubkey": "<base64 raw pubkey>", "alg": "Ed25519", "name": "Ops floor iPad", "push_platform": "apns" } }201 returns { tenant_id, human_id, device_id, session: { token } } — persist all four. Ed25519 (32-byte raw pubkey) is the default and preferred; P256 (65-byte uncompressed 0x04||X||Y) is for platforms where hardware keys are ECDSA-only.
Workos mode (hosted): registration is POST /v1/devices under an AuthKit session — same pubkey/alg shapes. Check GET /.well-known/humanauth → auth_mode to branch.
2 — Maintain a session (operator mode)
Section titled “2 — Maintain a session (operator mode)”Sessions are short (1 h). Re-mint by proving key possession:
POST /v1/sessions/device/challenge { "human_id": "hu_…", "device_id": "dev_…" }→ { "challenge": "<token>" }
POST /v1/sessions/device{ "challenge": "<token>", "signature": "<base64url sig over the UTF-8 bytes of the whole challenge string>" }→ { "token": "<Bearer session>", "expires_at": … }3 — Fetch what’s pending
Section titled “3 — Fetch what’s pending”GET /v1/devices/{device_id}/requests Authorization: Bearer <session>Returns { requests: [ { request_id, action, description, plan, severity, expires_at, collect_schema?, human_id, device_id, … } ] } — everything needed to render the decision. GET /v1/devices/{device_id}/history serves the frozen terminal-state list. Poll while backgrounded push is not yet wired; respect expires_at (a lapsed request answers 410).
Render faithfully. The human must see the action, the human-readable description, and the plan’s material parameters (amount, target, environment). What you display is what their signature will mean.
4 — Decide and co-sign
Section titled “4 — Decide and co-sign”On the human’s confirmation (behind the biometric gate):
- Read the device clock:
decided_at = floor(now / 1000). - Build the cosig message:
plan_hash||request_id||huid||device_id||decided_at(UTF-8, two-char||separators,decided_atin decimal). - Sign it with the device key → 64-byte raw signature, base64url.
- Build the X-Device-Proof header for this specific call.
- Submit:
POST /v1/respond/{request_id}Authorization: Bearer <session> X-Device-Proof: <proof>{ "human_id": "hu_…", "device_id": "dev_…", "decision": "approved", "device_signature": "<base64url 64B>", "device_pubkey": "<base64url raw>", "assurance": "biometric", "decided_at": 1755194742, "reason": "…", "form_data": { … } }Denials use decision: "denied" with a reason (the official apps make it mandatory; receipts carry only its sha256: commitment, never the text). form_data answers a collect_schema when the request asked the human to supply fields. The platform verifies your signature against the registered pubkey before minting — a wrong byte layout is rejected at this door, which makes development pleasantly fail-fast.
5 — What happens to your signature
Section titled “5 — What happens to your signature”It is embedded verbatim in the receipt’s approvers[] entry and re-verified offline by every resource server that enforces the receipt — your co-signature is checked twice by two different parties against two copies of the pubkey (roster and receipt). If the decision satisfies the policy (quorum, unanimous, or Cedar), the receipt mints within the same request.
Testing your implementation
Section titled “Testing your implementation”Run the whole loop against a local platform (wrangler dev with AUTH_MODE=operator) or a self-hosted deployment: mint an invite with humanauth invite create --qr, enroll, fire a request with humanauth test <action>, approve from your implementation, and check the receipt with humanauth verify <jws> --issuer <yours>. The verifier’s typed errors name exactly which check a malformed cosignature fails.