Skip to content

Protocol Internals

This page is the normative reference for HARP v2 (Human Approval Receipt Protocol) as shipped — every byte layout, claim name, and constant here is the one the production platform, apps, and verifier use. Conceptual treatments live in the Architecture and Security Model pages; this is the wire.

The v1 relay protocol (end-to-end encrypted relay envelopes, QR pairing) is superseded and no longer served; its documentation lives in repository history.

Four parties — agent backend, platform, approver device, resource server — and prefixed identifiers throughout:

PrefixEntityPrefixEntity
ten_tenantreq_authorization request
hu_humanrct_receipt (jti)
dev_devicepol_Cedar policy
grp_groupinv_enrollment invite
key_API key (ha_live_…/ha_test_… secret)ses_session
  1. Request TTL (10 s – 1 h, set at authorize): how long the human has to decide. Expiry mints no receipt — nobody signed anything.
  2. Receipt expiry (exp − iat ≤ 600 s from mint): how long the agent has to present the proof. Replay stores therefore only ever track ten minutes of jtis.

POST /v1/authorize (API-key auth; the key is the principal) → platform canonicalizes the plan, stamps plan_hash and the tenant’s required_assurance, creates req_… → fan-out to every registered device → devices respond with co-signatures → policy evaluation (quorum N-of-M with early-deny, unanimous with veto, or a Cedar policy) → receipt minted on decision → outcome delivered by receipt pull (waitForReceipt / GET /v1/requests/:id) and pushed via request.decided / request.expired webhooks to the agent backend. States: pending → delivered → decided, or expired (lazy flip + webhook), or cancelled. Late responses receive 410.

plan_hash = hex( SHA-256( JCS(plan) ) )

JCS is RFC 8785 JSON Canonicalization. The verifier recomputes this from the request the resource server is actually about to execute and byte-compares — the lying-agent killer. Content limits at issuance: action ≤ 200 chars (^[a-z][a-z0-9_.]*:[a-z][a-z0-9_]*$ — the scope-like service:operation label), description ≤ 4000, plan ≤ 32 768 bytes, reason ≤ 1000.

Compact JWS, EdDSA, per-tenant key:

header: { "alg": "EdDSA", "kid": "<tenant_id>:k_<n>", "typ": "humanauth-receipt+jws;v=2" }
claims: iss = deployment issuer (hosted: https://api.humanauth.ai; self-host: HA_ISSUER)
aud = tenant_id sub = human_id
jti = rct_<32 hex> iat, nbf, exp (exp − iat ≤ 600)
ha = { v: 2, request_id, action, result, plan_hash,
plan_alg: "sha256-canonical-v1", approvers: [ApproverEntry…],
policy_id?, policy_hash?, rule_satisfied?, org_snapshot_id?,
required_assurance? }

typ is deliberately not JWT: a strictly-validating auth stack will never accept a receipt as a bearer token. Denials mint full receipts (result: "denied"); expiry mints none.

ApproverEntry (one per co-signer, max quorum 10):

{ huid, device_id,
device_pubkey, // base64url raw: Ed25519 32B | P256 65B uncompressed 0x04||X||Y
device_alg?, // "Ed25519" (default) | "P256"
assurance, // "tap" | "biometric" | "elevated"
decided_at, // unix seconds, device clock, server-verified ±300s
device_sig, // base64url raw 64B: Ed25519 | ECDSA-P256 r||s (no DER)
reason_hash? } // "sha256:<hex>" commitment — plaintext reasons never travel

The message the device signs is the UTF-8 bytes of five fields joined with a two-character || separator:

plan_hash || request_id || huid || device_id || decided_at

(e.g. sha256:ab12…||req_9f…||hu_7c…||dev_1a…||1755194742 — decided_at as decimal string). Ed25519 signs the raw bytes; P256 signs ECDSA/SHA-256 with the 64-byte r||s encoding. The platform verifies this before minting against the device’s registered public key, and the verifier re-verifies it offline against the pubkey embedded in the receipt. This is the second, independent signature of the dual-signature chain — a compromised platform key alone cannot fabricate it.

The device-proof header (per respond call)

Section titled “The device-proof header (per respond call)”

POST /v1/respond/:request_id additionally requires DPoP-style proof that the caller holds the device key right now:

X-Device-Proof: <base64url(payload_json)>.<base64(signature)>
payload: { "htm": "POST", "htu": "/v1/respond/req_…", "iat": <unix s>, "nonce": "<b64 16B>" }

Signature over the UTF-8 payload bytes; iat within ±60 s of server time. A stolen session token without the device’s private key cannot approve anything.

Respond body: { human_id, device_id, decision: "approved"|"denied", reason?, device_signature, device_pubkey, assurance?: "tap"|"biometric"|"hardware", decided_at?, form_data? } — wire value hardware surfaces as elevated in receipts. Send decided_at; legacy clients that omit it force the platform to scan a bounded window of candidate seconds.

The bare self-host tier (self-hosting guide) replaces IdP sessions with platform-native primitives:

  • Invites: admin mints ha_inv_<26 hex> (stored hash-only) → POST /v1/invites/redeem { code, device: { pubkey, alg?, name?, push_platform?, push_token? } } atomically claims the code, binds the human, registers the key, returns the first session. The connect QR payload is harp://connect?v=1&url=<platform>&code=<invite>.
  • Device sessions: two-segment compact tokens base64url(payload).base64url(sig), platform-Ed25519-signed, typ inside the payload — humanauth-challenge+sig (120 s) and humanauth-session+sig (1 h, Bearer). Mint: fetch a challenge at POST /v1/sessions/device/challenge, sign the whole challenge token string with the device key, submit to POST /v1/sessions/device. Deliberately not JWTs, same typ-discipline as receipts.

GET /.well-known/humanauth advertises api_base (deployment-aware), auth_mode (workos | operator), workos_client_id (workos mode), and the platform’s Ed25519 public key — everything an approver app needs to serve any deployment from one build.

The resource server runs the ten-check offline chain ending in the atomic (jti, idempotencyKey) replay claim — see the Verifier SDK for enforcement and the Build an Approver page for the device-side contract.