@humanauth/verifier
v0.4.0
Published
Backend SDK that cryptographically enforces human approval on protected operations. Verify a HumanAuth receipt JWS, check it against the action and plan your service is about to execute, and atomically reserve the receipt's replay slot — all in one call.
Readme
@humanauth/verifier
Backend SDK that cryptographically enforces human approval on protected operations. Verify a HumanAuth receipt JWS, check it against the action and plan your service is about to execute, and atomically reserve the receipt's replay slot — all in one call. Built to the contract in docs/superpowers/specs/2026-05-30-receipts-v2-verifier-and-policy.md.
Install
npm install @humanauth/verifierPlus one replay-store backend (peer dep, optional):
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 onlyexpress is a peer dep only if you use the Express middleware sugar.
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
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 below | none | new 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
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.
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
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.
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.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
For tests and air-gapped CI, mint your own signed receipts using the fixtures published at the @humanauth/verifier/fixtures subpath:
import { generatePlatformKeys, generateDeviceKeys, mintTestReceipt, mockJwks } from "@humanauth/verifier/fixtures";
const platformKeys = await generatePlatformKeys();
const deviceA = await generateDeviceKeys();
const jws = await mintTestReceipt({ plan, action, audience, subject, approvers: [...], platformKeys, deviceKeys: ... });
const verifier = new HumanAuthVerifier({
audience: "ten_test",
jwks: await mockJwks(platformKeys.publicKey, platformKeys.kid),
replayStore: new MemoryReplayStore({ allowInProduction: true }),
});These helpers are part of the published package — no vendoring required. They are intentionally NOT exported from the package root; you must import from the /fixtures subpath to make the test/dev-only intent explicit.
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.
Planned for 0.4.0 (spec pass)
- Receipt-by-reference transport:
x-humanauth-receipt: rct_<id>accepted by the Express middleware (fetch + cache), removing the inline header-size ceiling for large quorums. Inline stays the default — offline verification is the point. reason_hashsurfacing: helper to compare a receipt's per-approverreason_hashcommitment against a reason on record.
