Verifier SDK
The verification fence
Section titled “The verification fence”Five checks, in order. The first that fails throws a typed error and the action never executes — that’s the whole point.
What is the verifier?
Section titled “What is the verifier?”@humanauth/verifier is the enforcement half of HumanAuth — it runs at the resource server, the service or gateway that actually executes the action. It takes a receipt JWS, verifies it against the platform’s JWKS, byte-compares the plan hash against what your service is actually about to execute, atomically reserves the (jti, idempotencyKey) replay slot, and returns a typed VerifiedReceipt — or throws a typed error. No network call to the platform on the hot path. Verification is offline; only the JWKS fetch (cached, default 1h) touches the network. This is the trust boundary that makes “no receipt, no execution” actually true.
Install
Section titled “Install”npm install @humanauth/verifierPlus one replay-store backend (peer dep, pick the one matching your runtime):
npm install ioredis # Redisnpm install pg # Postgresnpm install better-sqlite3 # SQLite# Workers KV — no extra package, uses your env.MY_KV binding# Memory — no extra package, dev/test onlyexpress is a peer dep only if you use the Express middleware sugar.
Quickstart
Section titled “Quickstart”import { HumanAuthVerifier } from "@humanauth/verifier";import { RedisReplayStore } from "@humanauth/verifier/stores/redis";import Redis from "ioredis";
const verifier = new HumanAuthVerifier({ audience: "ten_acme", jwksUri: "https://api.humanauth.ai/.well-known/jwks.json", replayStore: new RedisReplayStore(new Redis()),});
const verified = await verifier.requireReceipt(receiptJws, { action: "github:delete_repo", plan: { repo: "acme/legacy-service" }, idempotencyKey: req.headers["idempotency-key"],});
// verified.subject — huid of the approving user// verified.approvers — array with device_id, assurance, decided_at// verified.replay — true if this (jti, idem) was seen before// verified.firstClaimAt — Date of the first successful claim// verified.expiresAt — Date the receipt expiresFailures throw a HumanAuthVerifierError subclass with a stable code. Catch the base class for blanket handling or specific subclasses for fine-grained routing:
import { PlanHashMismatchError, ActionMismatchError, ReplayConflictError,} from "@humanauth/verifier";waitForReceipt — poll then verify
Section titled “waitForReceipt — poll then verify”requireReceipt assumes you already hold the receipt JWS. Often you don’t — the agent fired POST /v1/authorize, has only a request_id, and the human hasn’t decided yet. waitForReceipt closes that gap in one call: it polls GET /v1/requests/:id with capped backoff until the request is decided, then runs the full requireReceipt pipeline on the receipt the platform embeds in that decided response — so a single poll returns both the status and the signed receipt, with no second /v1/receipts hop.
import { HumanAuthVerifier } from "@humanauth/verifier";
const verifier = new HumanAuthVerifier({ audience: "ten_…", jwksUri: "https://api.humanauth.ai/.well-known/jwks.json", replayStore,});
const receipt = await verifier.waitForReceipt(requestId, { apiKey: process.env.HUMANAUTH_API_KEY!, action: "payments:transfer", plan, idempotencyKey: "…",});// throws ApprovalDeniedError / ApprovalTimeoutError; else returns a VerifiedReceiptapiKey, action, and plan are required; baseUrl, idempotencyKey, timeoutMs, pollIntervalMs, maxPollIntervalMs, fetch, and signal are optional. It resolves to a VerifiedReceipt, or throws a typed error — ApprovalDeniedError (the human said no), ApprovalExpiredError (the TTL elapsed with no decision), ApprovalTimeoutError (your timeoutMs elapsed first), or RequestPollError (a poll request failed). And because it finishes with requireReceipt, the verification-fence errors above — plan-hash mismatch, replay conflict, and the rest — can throw too.
Assurance floor. requireReceipt — and therefore waitForReceipt — also enforces the action’s tenant-required assurance level. If any approver decided below the level the action demands (tap < biometric < elevated), it throws AssuranceInsufficientError. That level is set by tenant policy on the action, stamped on the request as required_assurance, and checked server-side, so an agent cannot downgrade to a weaker approval than policy requires.
Replay store adapters
Section titled “Replay store adapters”The replay store is the source of truth for (jti, idempotencyKey) dedup. Pick the one that matches your runtime.
Ownership: yours by design, mandatory by spec
Section titled “Ownership: yours by design, mandatory by spec”You own the replay store — specifically, the resource server does. HumanAuth never sees it, never writes to it, and can’t: verification is offline, so the replay memory must live where verification runs. Any design where the platform hosted the “has this jti been spent?” ledger would put an online oracle back on your hot path.
But integrator-owned does not mean optional. Three layers, hard to soft:
- Protocol — mandatory. Single-use
jtisemantics are normative in the receipts spec: the final step of the verification chain is the atomic claim of(jti, idempotencyKey), with all three outcomes specified — claimed → proceed; same pair again → safe retry,verified.replay = true; samejtiunder a different key →ReplayConflictError. A verifier that skips the claim is non-compliant, the same way one that skips the plan-hash comparison would be. - SDK — no opt-out.
replayStoreis a required constructor field, andrequireReceipt()performs the claim on every call. There is no flag to disable it; the loosest you can go is the memory store, which exists so dev and tests work — not so the check can be absent. - Deployment — the judgment call. Which backend you pick and how you share it (one claim domain per enforcement boundary, below) is yours. We can’t see or validate your topology, precisely because verification is offline.
Placement follows one rule: the party that executes must be the party that remembers what it executed. The agent backend is the retry-er — the party whose duplicate presentations the store exists to absorb — so it keeps no replay state; the resource server keeps the memory of what actually ran. What it stores is tiny and non-sensitive: (jti, idempotency-key) pairs, prunable after the receipt’s exp — roughly ten minutes of claims, ever, since exp − iat ≤ 600s. No receipt contents, no PII.
The same structure appears in DPoP (RFC 9449), where the proof carries a jti and the resource server keeps its own replay cache, and in TLS, where certificate validation is protocol but the trust store lives on your machine. It also explains how HARP’s two promises are enforced differently: “no receipt, no action” is enforced by cryptography — nobody can forge the dual signatures; “one receipt, one action” is enforced by verifier discipline — mandated by the spec, hard-required by this SDK, running on your side of the trust boundary.
Here is that discipline in motion — the first presentation burns the jti and the tool runs; the second presentation of the same valid receipt fails the atomic claim and the gateway slams shut:
One receipt, one execution · all flows full-screen
Choosing a backend
Section titled “Choosing a backend”One claim domain per enforcement boundary. Replay claims only protect the store they’re written to: if five replicas of your service each run an independent store (five MemoryReplayStores, five separate SQLite files), the same receipt can be presented once to each replica — five executions of one approval. The rule: every process that enforces the same class of action must share one replay store (Redis/Postgres for replicated services; SQLite is fine for a genuinely single-node service). Restarts matter for the same reason — an in-memory store forgets its claims on restart, reopening the replay window for any receipt still inside its exp. This is also why receipt expiry is deliberately short (≤10 minutes): even a worst-case store wipe bounds exposure to minutes, and your business layer’s own idempotency (dedupe wires by transaction ID) remains the final backstop.
| Backend | When to pick | Atomicity | Peer dep | Construct |
|---|---|---|---|---|
| Memory | Local dev, tests | Process-local, lost on restart | none | new MemoryReplayStore() |
| SQLite | Single-node services, edge sidecars | INSERT OR IGNORE + transaction | better-sqlite3 | new SqliteReplayStore(db) |
| Redis | Most production deployments | SET NX EX (atomic, single round-trip) | ioredis | new RedisReplayStore(redis) |
| Postgres | Already running Postgres, want one less moving part | INSERT ... ON CONFLICT DO NOTHING | pg | new PostgresReplayStore(pool) |
| Workers KV | Cloudflare Workers, low-contention only | NOT ATOMIC — see warning | none | new WorkersKvReplayStore(env.MY_KV) |
// Memory — refuses to construct under NODE_ENV=production unless opted inimport { MemoryReplayStore } from "@humanauth/verifier/stores/memory";const store = new MemoryReplayStore();
// SQLiteimport { SqliteReplayStore } from "@humanauth/verifier/stores/sqlite";import Database from "better-sqlite3";const store = new SqliteReplayStore(new Database("/var/lib/app/replay.db"));
// Redisimport { RedisReplayStore } from "@humanauth/verifier/stores/redis";import Redis from "ioredis";const store = new RedisReplayStore(new Redis(process.env.REDIS_URL!));
// Postgresimport { PostgresReplayStore } from "@humanauth/verifier/stores/postgres";import { Pool } from "pg";const store = new PostgresReplayStore(new Pool({ connectionString: process.env.DATABASE_URL }));
// Workers KV — low-contention only, see warning belowimport { WorkersKvReplayStore } from "@humanauth/verifier/stores/workers-kv";const store = new WorkersKvReplayStore(env.REPLAY_KV);Workers KV warning. Cloudflare KV has no compare-and-swap and is eventually consistent (~60s globally). Two concurrent requests can both claim the same jti with different idempotencyKeys — neither will see the other’s write in time. Use only on routes where double-execution is acceptable, or front it with Durable Objects.
Operations: schema, security, and availability
Section titled “Operations: schema, security, and availability”Schema and setup. Zero-migration by default. The Postgres adapter auto-provisions its table on first use — receipt_claims (jti TEXT PRIMARY KEY, idem TEXT, first_claim_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL) plus an index on expires_at; the table name is configurable via the tableName option. SQLite creates the same shape in its constructor. Prefer explicit migrations? Pre-create the table — the adapter’s CREATE TABLE IF NOT EXISTS becomes a no-op, and the runtime role then needs only INSERT/SELECT/DELETE on that one table. Redis has no schema: one key per receipt (ha:rcpt:<jti>, prefix configurable via keyPrefix) holding the idempotency key and first-claim timestamp, with the key TTL set from the receipt’s remaining life.
Pruning. Redis expires its keys itself — zero maintenance. The SQL stores evict a jti’s expired row lazily when that same jti is presented again, but rows for receipts never re-presented stay behind; schedule a periodic DELETE FROM receipt_claims WHERE expires_at <= NOW() (the expires_at index exists for exactly this). Rows are ~100 bytes and the live set is only ever ~10 minutes of verifications — the sweep is hygiene, not capacity planning.
Security. The contents are not the sensitive part — a jti, an idempotency key, two timestamps; no receipt contents, no PII. Write access is the sensitive part: an attacker who can delete rows (or FLUSHDB) reopens the replay window for any receipt still inside its exp (bounded to minutes by design), and one who can insert rows can deny service by pre-claiming jtis. Treat the store as the resource server’s own state: same trust zone, authenticated and TLS’d connections, least privilege scoped to the one table or key prefix, never shared with untrusted applications. tableName/keyPrefix exist so shared infrastructure can still isolate.
Availability — the verifier fails closed. requireReceipt() does not catch store errors: if the replay store is unreachable, the claim throws, verification fails, and the action does not run. Store downtime is a controlled outage, never a silently-open replay window. Plan the store’s availability like a tier-1 dependency, because it is the enforcement path’s only stateful one — everything else the verifier touches is a cached JWKS.
Failover nuance. Redis replication is asynchronous: a failover can lose the newest claims, so a receipt claimed just before the switch could be claimable once more on the promoted replica — bounded by the ≤10-minute expiry, and backstopped by your business layer’s own idempotency (dedupe wires by transaction ID). Where even that bounded window is unacceptable, use Postgres — claims are durable at commit; pair with synchronous replication — or Redis with WAIT. SQLite has no failover story by design; it is the single-node option.
Retries require an Idempotency-Key. When no idempotencyKey is supplied, any second presentation of the same jti is refused (ReceiptReplayedError) — the safe-retry path (verified.replay = true) exists only for callers that send a key. Agents that might ever retry should always send one.
Express middleware
Section titled “Express middleware”import express from "express";import { humanAuth } from "@humanauth/verifier/express";
const app = express();app.use(express.json());
app.post( "/repos/:owner/:repo", humanAuth({ verifier, action: "github:delete_repo", planFromReq: (req) => ({ repo: `${req.params.owner}/${req.params.repo}` }), // Defaults: receiptHeader "x-humanauth-receipt", idempotencyHeader "idempotency-key" }), async (req, res) => { // req.humanAuth is the verified receipt await deleteRepo(req.params.owner, req.params.repo); res.json({ ok: true, replay: req.humanAuth!.replay }); },);Failure responses:
401 { error: "MISSING_RECEIPT" }— receipt header absent400 { error: "MISSING_IDEMPOTENCY_KEY" }— idempotency header absent403 { error: "<CODE>", message: "..." }— verification failed;<CODE>is the stable error code (PLAN_HASH_MISMATCH,ACTION_MISMATCH,RECEIPT_EXPIRED, etc.)
Failures are short-circuited at the middleware boundary — next(err) is never called, so a downstream error handler cannot accidentally swallow an authorization failure.
MCP tool wrapper
Section titled “MCP tool wrapper”import { wrapMcpTool } from "@humanauth/verifier/mcp";
const deleteRepo = wrapMcpTool( { verifier, action: "github:delete_repo", plan: (params) => ({ repo: params.repo }), // Defaults: receiptFrom: p => p.__ha_receipt, idempotencyFrom: p => p.__ha_idem }, async (params: { repo: string }) => { await github.repos.delete(params.repo); return { deleted: true }; },);
// Register `deleteRepo` with your MCP server as the tool handler.// Callers pass __ha_receipt and __ha_idem alongside the tool's domain params.On success, the wrapper returns the handler’s result with __ha_verified attached (when the result is a plain object). On failure, the typed verifier error is thrown — the wrapped handler is not invoked.
Typed errors
Section titled “Typed errors”Every failure path throws a subclass of HumanAuthVerifierError. Every subclass has a stable code string so you can route on it without instanceof gymnastics.
| Error class | code | Fires when |
|---|---|---|
AudienceMismatchError | AUD_MISMATCH | Receipt’s aud claim does not equal the verifier’s configured audience. Cross-tenant receipt rejected. |
PlanHashMismatchError | PLAN_HASH_MISMATCH | Canonical-JSON SHA-256 of the caller’s plan does not byte-match the receipt’s plan_hash. The human approved a different plan. |
ActionMismatchError | ACTION_MISMATCH | Receipt’s action does not equal the caller’s expected action. The human approved a different action. |
ActionFormatError | ACTION_FORMAT | Either side’s action is not the canonical service:operation form. |
ReceiptExpiredError | RECEIPT_EXPIRED | exp claim is in the past (allowing clockSkewSec). |
ReceiptValidityError | RECEIPT_VALIDITY | exp - iat exceeds the 600s protocol ceiling. The platform caps this at mint time; the verifier enforces it too, so a compromised or buggy issuer cannot hand you a long-lived receipt. |
ReceiptNotApprovedError | NOT_APPROVED | ha.result is denied or expired. Receipt was minted but the human said no. |
JwsSignatureError | JWS_SIGNATURE | Outer JWS signature does not verify against any key in the JWKS. |
PlatformSignatureError | PLATFORM_SIG | Platform Ed25519 signature on the envelope did not verify. |
DeviceSignatureError | DEVICE_SIG | One or more per-approver device cosignatures failed to verify. |
ReplayConflictError | REPLAY_CONFLICT | (jti, idempotencyKey) already claimed by a different idempotencyKey. Distinct from the “replay = true” success case where the SAME key replays. |
JwksError | JWKS | JWKS fetch failed, cache empty, or no key with the receipt’s kid was found. |
InvalidEnvelopeError | INVALID_ENVELOPE | Receipt is missing required claims, has an unknown alg, carries a crit header, or the ha block is malformed. |
Security defaults (non-overridable)
Section titled “Security defaults (non-overridable)”Per spec §5.2 — the verifier is opinionated by design:
- Algorithm allow-list. Only
EdDSA(Ed25519). Noalg: none, no HMAC, no RSA. audrequired and checked. Receipts addressed to a different tenant are rejected.isspinned. Defaulthttps://api.humanauth.ai; override viaissuerfor self-hosted deployments. Defense-in-depth against JWKS-confusion attacks.critheader rejected. No critical extensions accepted.expenforced. Expired receipts always fail.- Validity ceiling enforced. A receipt whose
exp - iatexceeds 600s is refused regardless of who signed it, bounding what a replay store has to remember. - Unrecognised assurance fails closed. An approver entry whose
assuranceis not one oftap,biometric,elevatedis rejected rather than assumed adequate. - Approver entries are fully type-checked. Malformed entries raise
INVALID_ENVELOPErather than an untyped error. - Clock skew bounded. Default 60s, configurable via
clockSkewSec. No cap — set it as wide as you can defend; tighter is better. - Plan hash byte-compared. Canonical JSON (RFC 8785) + SHA-256, compared byte-for-byte against the receipt’s
plan_hash. - Per-approver device cosig REQUIRED. Every approver’s Ed25519 device signature is verified against the canonical cosig message.
idempotencyKeyREQUIRED. There is no opt-out. The platform’s atomicclaim(jti, idem)is the at-most-once guarantee — skipping it would let attackers replay receipts.
Verifying from another language
Section titled “Verifying from another language”The verifier package is TypeScript, but verification is offline JWS — fully reproducible in any language with an EdDSA-capable JOSE library and an RFC 8785 canonical JSON encoder. The platform’s wire format is the contract; this package is one (opinionated) implementation of it.
The Integrator Cookbook → Verify a receipt on your backend ships drop-in equivalents in:
- Python —
PyJWT+cryptography+rfc8785+ Redis for the replay slot. - Java — Nimbus JOSE+JWT +
erdtman/java-json-canonicalization.
Other languages: any JWS library that supports alg: EdDSA plus an RFC 8785 encoder works. The five invariants you MUST enforce, in any implementation:
- JWS signature against a key from
${HA_BASE}/.well-known/jwks.json(cache 1h). audequals your tenant id;issequalshttps://api.humanauth.ai;expin the future (allow ≤60s skew).ha.plan_hash == hex(sha256(rfc8785(plan)))— byte-compare, not==on parsed JSON.claims.actionequals what your resource server is about to execute.- Atomic claim of
(jti, idempotency_key)against a replay store before the action runs.
Skip any one of these and the trust boundary leaks.
Working without a live platform
Section titled “Working without a live platform”For tests and air-gapped CI, mint your own signed receipts using the helpers shipped in the package itself, imported from the @humanauth/verifier/fixtures subpath:
import { generatePlatformKeys, generateDeviceKeys, mintTestReceipt, mockJwks,} from "@humanauth/verifier/.../fixtures/sign";// Or vendor the file directly into your own test tree.
const platformKeys = await generatePlatformKeys();const deviceA = await generateDeviceKeys();const jws = await mintTestReceipt({ plan, action, audience, subject, approvers: [/* ... */], platformKeys, deviceKeys: deviceA,});
const verifier = new HumanAuthVerifier({ audience: "ten_test", jwks: await mockJwks(platformKeys.publicKey, platformKeys.kid), replayStore: new MemoryReplayStore({ allowInProduction: true }),});The fixtures live under __tests__/ and are not published in the package — copy them into your test tree or pin to a tag and import from source.
What this package does NOT do
Section titled “What this package does NOT do”- Cedar policy evaluation. Policies (who can approve which action under what conditions) are evaluated platform-side and reflected in the receipt’s
rule_satisfied/policy_idfields. Trust the receipt; don’t re-run the policy. - Receipt issuance. This is a verifier, not an issuer. The platform mints receipts after the human approves.
- Key management. Signing keys live on the platform. The verifier only consumes the public JWKS.
See also
Section titled “See also”- Protocol Internals — wire format and state machine
- Security Model — threat model and cryptographic primitives
- SDK Reference — agent-side API for sending requests