Verifier SDK
What is the verifier?
Section titled “What is the verifier?”@humanauth/verifier is the backend-side enforcement half of HumanAuth. 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. Design rationale lives in docs/superpowers/specs/2026-05-30-receipts-v2-verifier-and-policy.md.
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";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.
| 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.
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). |
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.- 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.
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 fixtures at packages/verifier/src/__tests__/fixtures/sign.ts:
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