Skip to content

Verifier SDK

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

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

The replay store is the source of truth for (jti, idempotencyKey) dedup. Pick the one that matches your runtime.

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.

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).
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.
  • 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.

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.

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