Skip to content

Verifier SDK

Five checks, in order. The first that fails throws a typed error and the action never executes — that’s the whole point.

@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.

Terminal window
npm install @humanauth/verifier

Plus one replay-store backend (peer dep, pick the one matching your runtime):

Terminal window
npm install ioredis # Redis
npm install pg # Postgres
npm install better-sqlite3 # SQLite
# Workers KV — no extra package, uses your env.MY_KV binding
# Memory — no extra package, dev/test only

express is a peer dep only if you use the Express middleware sugar.

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 expires

Failures 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";

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 VerifiedReceipt

apiKey, 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.

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 jti semantics 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; same jti under 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. replayStore is a required constructor field, and requireReceipt() 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

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.

BackendWhen to pickAtomicityPeer depConstruct
MemoryLocal dev, testsProcess-local, lost on restartnonenew MemoryReplayStore()
SQLiteSingle-node services, edge sidecarsINSERT OR IGNORE + transactionbetter-sqlite3new SqliteReplayStore(db)
RedisMost production deploymentsSET NX EX (atomic, single round-trip)ioredisnew RedisReplayStore(redis)
PostgresAlready running Postgres, want one less moving partINSERT ... ON CONFLICT DO NOTHINGpgnew PostgresReplayStore(pool)
Workers KVCloudflare Workers, low-contention onlyNOT ATOMIC — see warningnonenew WorkersKvReplayStore(env.MY_KV)
// Memory — refuses to construct under NODE_ENV=production unless opted in
import { MemoryReplayStore } from "@humanauth/verifier/stores/memory";
const store = new MemoryReplayStore();
// SQLite
import { SqliteReplayStore } from "@humanauth/verifier/stores/sqlite";
import Database from "better-sqlite3";
const store = new SqliteReplayStore(new Database("/var/lib/app/replay.db"));
// Redis
import { RedisReplayStore } from "@humanauth/verifier/stores/redis";
import Redis from "ioredis";
const store = new RedisReplayStore(new Redis(process.env.REDIS_URL!));
// Postgres
import { 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 below
import { 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.

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 absent
  • 400 { error: "MISSING_IDEMPOTENCY_KEY" } — idempotency header absent
  • 403 { 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.

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.

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 classcodeFires when
AudienceMismatchErrorAUD_MISMATCHReceipt’s aud claim does not equal the verifier’s configured audience. Cross-tenant receipt rejected.
PlanHashMismatchErrorPLAN_HASH_MISMATCHCanonical-JSON SHA-256 of the caller’s plan does not byte-match the receipt’s plan_hash. The human approved a different plan.
ActionMismatchErrorACTION_MISMATCHReceipt’s action does not equal the caller’s expected action. The human approved a different action.
ActionFormatErrorACTION_FORMATEither side’s action is not the canonical service:operation form.
ReceiptExpiredErrorRECEIPT_EXPIREDexp claim is in the past (allowing clockSkewSec).
ReceiptValidityErrorRECEIPT_VALIDITYexp - 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.
ReceiptNotApprovedErrorNOT_APPROVEDha.result is denied or expired. Receipt was minted but the human said no.
JwsSignatureErrorJWS_SIGNATUREOuter JWS signature does not verify against any key in the JWKS.
PlatformSignatureErrorPLATFORM_SIGPlatform Ed25519 signature on the envelope did not verify.
DeviceSignatureErrorDEVICE_SIGOne or more per-approver device cosignatures failed to verify.
ReplayConflictErrorREPLAY_CONFLICT(jti, idempotencyKey) already claimed by a different idempotencyKey. Distinct from the “replay = true” success case where the SAME key replays.
JwksErrorJWKSJWKS fetch failed, cache empty, or no key with the receipt’s kid was found.
InvalidEnvelopeErrorINVALID_ENVELOPEReceipt is missing required claims, has an unknown alg, carries a crit header, or the ha block is malformed.

Per spec §5.2 — the verifier is opinionated by design:

  • Algorithm allow-list. Only EdDSA (Ed25519). No alg: none, no HMAC, no RSA.
  • aud required and checked. Receipts addressed to a different tenant are rejected.
  • iss pinned. Default https://api.humanauth.ai; override via issuer for self-hosted deployments. Defense-in-depth against JWKS-confusion attacks.
  • crit header rejected. No critical extensions accepted.
  • exp enforced. Expired receipts always fail.
  • Validity ceiling enforced. A receipt whose exp - iat exceeds 600s is refused regardless of who signed it, bounding what a replay store has to remember.
  • Unrecognised assurance fails closed. An approver entry whose assurance is not one of tap, biometric, elevated is rejected rather than assumed adequate.
  • Approver entries are fully type-checked. Malformed entries raise INVALID_ENVELOPE rather 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.
  • idempotencyKey REQUIRED. There is no opt-out. The platform’s atomic claim(jti, idem) is the at-most-once guarantee — skipping it would let attackers replay receipts.

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:

  1. JWS signature against a key from ${HA_BASE}/.well-known/jwks.json (cache 1h).
  2. aud equals your tenant id; iss equals https://api.humanauth.ai; exp in the future (allow ≤60s skew).
  3. ha.plan_hash == hex(sha256(rfc8785(plan))) — byte-compare, not == on parsed JSON.
  4. claims.action equals what your resource server is about to execute.
  5. Atomic claim of (jti, idempotency_key) against a replay store before the action runs.

Skip any one of these and the trust boundary leaks.

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.

  • 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_id fields. 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.