Skip to content

Security Model

  1. Dual-signature receipts. A valid receipt carries two signatures over the same canonical bytes: the platform’s (an EdDSA JWS) and the approver device’s co-signature, made with a key that exists only on that device and used behind the operating system’s gate. No attacker can make an enrolled device sign bytes it never signed — that key never leaves the device and cannot be exercised without the person. Two gaps currently sit between that property and the stronger claim that a compromised platform cannot assemble an approval at all; both are open and are described under Known gaps in the dual-signature property.
  2. Device-held keys. The Mac approver generates a P-256 key inside the Secure Enclave: hardware-bound, Touch-ID-gated, invalidated if biometrics are re-enrolled, never exportable. The mobile app generates an Ed25519 key on-device, stored in the system keychain (iOS Keychain / Android encrypted storage) behind the device unlock; it is never transmitted. We name the difference precisely because it matters: the Mac key is hardware-attested, the phone key is software-held.
  3. Offline verification. Relying parties verify receipts against the published JWKS — no API call, no dependency on HumanAuth being up, no dependency on HumanAuth existing. The device’s public key travels inside the receipt and is re-checked by the verifier over the same plan-hash-bound bytes.
  4. Plan-hash binding. What the human approves is a canonical SHA-256 (RFC 8785 JSON canonicalization) over the exact action and parameters. Change one byte of the parameters after approval and verification fails.

We publish these because a security property you cannot check is not a security property. Both are reproduced by the conformance suite that ships with @humanauth/verifier, and both have known remedies that are on the roadmap.

The verifier trusts the device key carried in the receipt. A relying party learns the approver’s device public key from the receipt itself, and the receipt is signed by the platform. A party holding the platform’s signing key can therefore mint an envelope that names a real human and a real device while embedding a key of its own. Every offline check passes. This is the known limit of issuer-attested holder binding, the same shape used by SD-JWT key binding and verifiable-credential presentations, and the remedy is the same one WebAuthn uses: the verifying party holds the device’s public key independently, from enrollment rather than from the artifact. A roster the verifier trusts, with provenance independent of the platform, closes it.

The co-signed bytes do not name the decision or the signer’s role. A device signs plan_hash ‖ request_id ‖ huid ‖ device_id ‖ decided_at. The result — approved or denied — lives only in the platform-signed envelope, and a principal’s on-behalf-of intent confirmation covers those same five fields. So a genuine denial co-signature, or a genuine intent confirmation, is byte-identical to what an approval would carry for that request. A party holding the platform key can re-label one as the other. A roster does not detect this, because the key involved is the real enrolled key. The remedy is a domain-separated co-signature message that carries a context string, a version, the signer’s role and the decision.

What to assume in the meantime. The second signature raises the cost of forging an approval under platform compromise; it does not by itself prevent it. Every other property on this page — plan-hash binding, audience binding, single use, offline verification — is unaffected, and all of them hold against a malicious agent or a network attacker, which are the adversaries most deployments are actually defending against.

PurposePrimitive
Platform receipt signaturesEd25519 (EdDSA compact JWS, per-tenant keys, kid-resolved via JWKS)
Mac device co-signatureECDSA P-256, Secure Enclave, biometry-gated
Mobile device co-signatureEd25519, keychain-stored
Plan hashSHA-256 over RFC 8785 canonical JSON
Device proof on respondDPoP-style signature over {htm, htu, iat, nonce}, ±60s freshness
Signing keys at restAES-256-GCM under a versioned KEK
Webhook authenticityHMAC-SHA256, Stripe-style t=<unix>,v1=<hex> header, secret rotation with overlap
TransportTLS 1.3 (Cloudflare edge)
ThreatDefense
Platform signing-key compromisePartial. A stolen platform key cannot make an enrolled device sign new bytes. It can embed an attacker-generated device key, or re-use a genuine denial or intent co-signature as an approval. See Known gaps.
Stolen session token (approver)POST /v1/respond additionally requires a fresh X-Device-Proof signature from a registered device key.
Receipt replaySingle-use jti, claimed atomically in the relying party’s replay store; receipts expire ≤600s after issuance.
Parameter mutation after approvalPlan-hash recomputation in the verifier fails on any change to action or parameters.
Cross-tenant receipt reuseaud is pinned to the tenant; verifiers reject foreign audiences.
Tampered audit trail (insider revision)A decision cannot be rewritten after the fact: altering any field breaks both the platform signature and the approver’s device co-signature, and no party — platform, tenant, or database admin — holds the keys to re-sign a modified history. Audit evidence verifies against published public keys, independent of any mutable store.
Forged device signatureThe platform verifies the co-signature against the device’s registered key before minting, and the verifier re-checks it against the pubkey embedded in the receipt. Note that the embedded key is what the offline verifier trusts — see Known gaps.
Stolen agent API keyThe attacker can create requests — every one still lands on a human’s screen with full context before anything is approved. Keys are SHA-256-hashed at rest, rotatable with a 24h overlap window, and rate-limited.
ThreatWhyMitigation
Compromised approver deviceA fully compromised device (with the user’s biometrics defeated) can approve requests.Device hygiene. The Mac’s Secure Enclave binding limits key theft even then.
Social engineeringA human can be talked into approving a well-worded malicious request.Plain-language descriptions, severity labels, anomaly flags, quorum policies for the irreversible tier.
Platform sees request contentRequest descriptions and parameters are processed by the platform and stored encrypted at rest — not end-to-end encrypted today. Do not put secrets in request payloads.E2E payload encryption is on the roadmap as an enterprise option; the v1 relay that provided it is retained in-repo as prior art.
Platform availabilityA down or malicious platform can fail to deliver requests (it still cannot forge approvals).Self-hosting under an Enterprise agreement; offline verification keeps already-issued receipts checkable forever, with the MIT-licensed verifier.
Traffic metadataThe platform necessarily sees who asked whom, when, and how often.Standard operational controls; audit log is tenant-visible.

@humanauth/verifier runs its checks in a fixed order, all offline: JWS parse → JWKS key resolution by kid → platform signature → issuer/audience/time-window claims → receipt type header → envelope schema → result is approved → per-approver device co-signature over plan_hash‖request_id‖huid‖device_id‖decided_at → plan-hash recomputation and byte-comparison → atomic replay claim. A failure at any step throws a typed error naming the step. These checks run at the resource server — the service that executes the action, never the agent backend that orchestrates it.

decided_at is the device’s own clock second at signing time, carried in the respond body and verified server-side within a ±300s skew window before the receipt is minted — the receipt embeds exactly the second that verified.

“Offline” means your enforcement path never calls HumanAuth: verification is local cryptography against our published public keys (fetched and cached like any OIDC JWKS; an air-gapped verifier can pin a snapshot). This is a deliberate security choice, not a shortcut — the same trust architecture as TLS certificates, OIDC ID tokens, and WebAuthn.

The classic offline objections, and how each is closed:

  • Replay — every receipt carries a random single-use jti, and the middleware keeps a local replay store. Because exp − iat ≤ 600s, the replay-tracking window is ten minutes of bounded memory, no global coordination.
  • Revocation — a receipt is evidence of a decision, not a standing credential: a human either approved at 14:02 or didn’t; there is nothing to revoke. The 10-minute expiry prevents hoarding; durable history lives in the platform record and your audit trail. Key revocation works the TLS way: JWKS rotation by kid, with cache refresh on unknown kid.
  • Token confusion — the header typ is humanauth-receipt+jws;v=2, deliberately not JWT, so a strictly-validating auth stack will never accept a receipt as an access token.
  • Platform-key compromise — the dual signature means a stolen platform key alone cannot fabricate a device co-signature.

The online alternative — “call our API to check” — would couple your checkout to our uptime, tell us which actions you verify and when, and create a single spoofable yes/no oracle. Offline verification is why “our uptime isn’t in your critical path” is literally true. One boundary to keep in mind: authenticity is an offline question; current status (pending, cancelled, latest state) is inherently online and served by the API — a receipt never claims to answer it beyond its 10-minute window.

The protocol places no limit on co-signers — each consent is an independent device signature, verified in a linear pass — but the product enforces a maximum consensus of 10 (quorum values, and membership of unanimous groups). This keeps a worst-case receipt around 4 KB: safe to pass inline in the default x-humanauth-receipt header under common proxy limits (8 KB per header line in nginx, 16 KB total in Node), and keeps the approver view legible. The Express middleware refuses oversized inline receipts explicitly (413 RECEIPT_TOO_LARGE, default guard 12 KB) rather than letting a proxy truncate them silently; larger receipts should travel in the request body or by reference (receipt ID lookup — planned).

Denials, expiry, and what the receipt proves

Section titled “Denials, expiry, and what the receipt proves”
  • Denials mint full receipts. A denial receipt carries result: "denied" with the denier’s device co-signature, verifiable exactly like an approval — “prove we blocked it” is first-class evidence.
  • Reasons never travel in receipts. Decision reasons (mandatory on denials) stay in the platform record; the receipt embeds only a reason_hash commitment (sha256:<hex>) per approver entry, so an auditor can later confirm the reason on file is the reason given at decision time without the plaintext transiting to third parties.
  • Expiry mints no receipt — by design. Nobody signed anything, so there is nothing to attest. Proof of non-consent is the absence of a receipt together with the platform record and the request.expired webhook event. A late approval attempt is refused with 410.

A receipt binds a decision to a human_id and a device key. Who vouched that the human_id is the person you think it is depends on the deployment, and the receipt’s iss claim tells you which regime applied:

  • Hosted (iss: https://api.humanauth.ai): enrollment requires an IdP-verified sign-in — HumanAuth attests the identity binding.
  • Self-hosted, operator mode (iss: the deployment’s own URL): enrollment happens via single-use invite codes minted by the tenant admin — the deployment operator attests the binding, exactly as they do for every other account system they run. The cryptography is identical; only the identity-proofing provenance differs.

In both regimes the device key, not any session or IdP token, is what signs the decision — and deactivating the device or human row in the roster is what revokes approver power.

WhereWhatProtection
Platform (Cloudflare)Per-tenant Ed25519 signing keysAES-256-GCM encrypted at rest under a versioned KEK; rotation publishes old + new in JWKS during overlap
Mac approverP-256 device keySecure Enclave; .biometryCurrentSet ACL; invalidated on biometric re-enrollment
Mobile approverEd25519 device keyexpo-secure-store (iOS Keychain / Android encrypted prefs); biometric gate at the app layer; never transmitted
Relying partyNothing secretVerification uses public keys only
  • Every security-relevant event (auth failures, key rotations, decisions, webhook deliveries) lands in a tenant-scoped audit log, exportable as CSV via GET /v1/audit.
  • Receipts are retained server-side (receipts_v2) and remain independently verifiable from the JWKS — your archive does not depend on ours.
  • Approver devices keep local decision history on-device.