Per-action hardware key verification for sensitive operations. Proves a human physically approved each action.
WebAuthn is great for login, but login only proves who you are, not what you approved. A compromised session can silently initiate transfers, change settings, or delete data without the user ever touching their key again.
There's no standard way to require a hardware key tap per action, and no way to prove the user actually read what they were approving before they tapped.
humankey is a TypeScript SDK that adds per-action hardware key verification with a built-in confirmation step. For every sensitive action, the user:
- Reads the action details and a confirmation code derived from the action itself
- Types the code back (proving they read and understood)
- Taps their hardware key (proving physical presence)
The server independently re-derives everything — a compromised client can't fake approval for a different action.
Drop-in adapters for Express, Next.js, Hono, and Fastify. A React hook for the client. Works with any FIDO2 key (YubiKey, Titan, platform authenticators).
1. Your server: createChallenge() → send to client
2. humankey: createConfirmation(action) → show code to user, user types it back
3. humankey: requestTap(challenge, action, confirmation) → user taps YubiKey → TapProof
4. Your client: send TapProof to your server
5. humankey/verify: verifyTapProof(proof, ...) → { verified, confirmationValid }
The confirmation code is derived from the action hash — a compromised client can't predict the code for a different action. The server re-derives everything independently.
npm install humankey @simplewebauthn/browser@simplewebauthn/browser is a peer dependency (only needed in the browser).
For framework adapters, install the framework alongside humankey:
npm install humankey express # Express
npm install humankey hono # Hono
npm install humankey fastify # FastifyFor the React hook:
npm install humankey @simplewebauthn/browser reactThe fastest way to add humankey to an Express app. Handles challenge lifecycle, registration, and verification automatically.
import express from 'express';
import { createHumanKeyRouter } from 'humankey/express';
import type { TapCredential } from 'humankey/verify';
const app = express();
app.use(express.json());
const credentials = new Map<string, TapCredential>();
app.use('/api', createHumanKeyRouter({
rpID: 'example.com',
rpName: 'My App',
origin: 'https://example.com',
getCredential: async (id) => credentials.get(id) ?? null,
onRegister: async (credential) => {
credentials.set(credential.id, credential);
},
onVerify: async (result, action) => {
console.log('Verified action:', action, result);
},
}));
app.listen(3000);This creates three routes:
POST /api/challenge— generates and stores a challenge, returns{ challengeId, challenge }POST /api/register— verifies registration, callsonRegister, returns{ ok, credentialId }POST /api/verify— verifies tap proof, callsonVerify, returns{ verified, confirmationValid, newCounter }
createHumanKeyRouter({
rpID: 'example.com', // Required: relying party ID
rpName: 'My App', // Required: relying party name
origin: 'https://example.com', // Required: expected origin(s)
getCredential: async (id) => ..., // Required: credential lookup
onRegister: async (cred) => ..., // Required: store new credentials
onVerify: async (result, action) => ..., // Optional: post-verification hook
challengeTTL: 60_000, // Optional: challenge TTL in ms (default: 60s)
challengeStore: customStore, // Optional: custom ChallengeStore (default: in-memory)
requireUserVerification: true, // Optional: require PIN/biometric (default: true)
allowedAAGUIDs: ['...'], // Optional: restrict authenticator models
});The default MemoryChallengeStore works for single-process deployments. For multi-server setups, implement the ChallengeStore interface:
import type { ChallengeStore } from 'humankey/express';
class RedisChallengeStore implements ChallengeStore {
constructor(private redis: RedisClient) {}
async set(id: string, challenge: string, ttlMs: number): Promise<void> {
await this.redis.set(`hk:${id}`, challenge, 'PX', ttlMs);
}
async get(id: string): Promise<string | null> {
const challenge = await this.redis.get(`hk:${id}`);
if (challenge) await this.redis.del(`hk:${id}`); // single-use
return challenge;
}
}For Next.js App Router. Each route handler is a separate file:
// lib/humankey-config.ts
import { createHumanKeyHandlers, MemoryChallengeStore } from 'humankey/nextjs';
import type { TapCredential } from 'humankey/verify';
// Persist in-memory state across Next.js HMR re-evaluations.
// Without this, dev mode re-creates the store between requests,
// causing "Challenge not found or expired" errors.
declare global {
// eslint-disable-next-line no-var
var __humankeyChallengeStore: MemoryChallengeStore | undefined;
// eslint-disable-next-line no-var
var __humankeyCredentials: Map<string, TapCredential> | undefined;
}
const challengeStore = global.__humankeyChallengeStore ??= new MemoryChallengeStore();
const credentials = global.__humankeyCredentials ??= new Map<string, TapCredential>();
export const hk = createHumanKeyHandlers({
rpID: 'example.com',
rpName: 'My App',
origin: 'https://example.com',
challengeStore,
getCredential: async (id) => credentials.get(id) ?? null,
onRegister: async (credential) => {
credentials.set(credential.id, credential);
},
});// app/api/humankey/challenge/route.ts
import { hk } from '@/lib/humankey-config';
export const POST = hk.challenge;// app/api/humankey/register/route.ts
export const POST = hk.register;
// app/api/humankey/verify/route.ts
export const POST = hk.verify;Uses the Web Request/Response API — no Next.js-specific types required.
import { Hono } from 'hono';
import { createHumanKeyApp } from 'humankey/hono';
import type { TapCredential } from 'humankey/verify';
const app = new Hono();
const credentials = new Map<string, TapCredential>();
app.route('/api', createHumanKeyApp({
rpID: 'example.com',
rpName: 'My App',
origin: 'https://example.com',
getCredential: async (id) => credentials.get(id) ?? null,
onRegister: async (credential) => {
credentials.set(credential.id, credential);
},
}));
export default app;import Fastify from 'fastify';
import { humanKeyPlugin } from 'humankey/fastify';
import type { TapCredential } from 'humankey/verify';
const app = Fastify();
const credentials = new Map<string, TapCredential>();
app.register(humanKeyPlugin, {
prefix: '/api',
rpID: 'example.com',
rpName: 'My App',
origin: 'https://example.com',
getCredential: async (id) => credentials.get(id) ?? null,
onRegister: async (credential) => {
credentials.set(credential.id, credential);
},
});
app.listen({ port: 3000 });The useHumanKey hook manages the full client-side flow: fetch challenge, show confirmation code, trigger hardware key tap, and verify.
import { useHumanKey } from 'humankey/react';
function TransferButton({ credentialId }: { credentialId: string }) {
const {
status,
confirmationCode,
error,
startAction,
confirmCode,
reset,
} = useHumanKey({ rpID: 'example.com', apiBase: '/api' });
const handleTransfer = async () => {
// Step 1: Start the action — fetches challenge, generates confirmation code
await startAction(
{ action: 'transfer', data: { to: 'bob', amount: 100 } },
[{ id: credentialId }],
);
// status is now 'confirming', confirmationCode is e.g. 'A7X3'
};
const handleConfirm = async (userInput: string) => {
// Step 2: User typed the code — triggers YubiKey tap and server verification
const proof = await confirmCode(userInput);
// status is now 'verified', proof contains the signed assertion
};
return (
<div>
{status === 'idle' && <button onClick={handleTransfer}>Send $100</button>}
{status === 'confirming' && (
<div>
<p>Type this code: <strong>{confirmationCode}</strong></p>
<input onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirm(e.currentTarget.value);
}} />
</div>
)}
{status === 'tapping' && <p>Tap your YubiKey...</p>}
{status === 'verified' && <p>Transfer approved!</p>}
{status === 'error' && <p>Error: {error?.message} <button onClick={reset}>Retry</button></p>}
</div>
);
}The hook also exposes a register function for one-time key registration:
const { register } = useHumanKey({ rpID: 'example.com' });
const result = await register('alice');
// result.credentialId — store this for future useimport {
verifyTapProof,
verifyRegistration,
createChallenge,
} from 'humankey/verify';
// Generate a challenge (base64url, 256-bit)
const challenge = createChallenge();
// After client registers a key, verify the registration
const { credential } = await verifyRegistration({
response: registrationResponseFromClient,
expectedChallenge: challenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
});
// → store credential server-side
// After client sends a TapProof, verify it
const result = await verifyTapProof({
proof,
credential, // stored TapCredential
expectedChallenge, // the challenge you generated
expectedAction: action, // your server's copy of the action
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
requireUserVerification: true,
requireConfirmation: true, // default: throws if code is wrong
});
// result.verified → signature is valid
// result.confirmationValid → user typed the correct code
// result.userVerified → biometric/PIN was used
// result.newCounter → update stored counterimport { createConfirmation, requestTap, registerKey, isHumanKeySupported } from 'humankey';
// Check support
if (!isHumanKeySupported()) {
throw new Error('WebAuthn not supported in this browser');
}
// One-time: register a hardware key
const registration = await registerKey({
challenge, // from your server
rpID: 'example.com',
rpName: 'My App',
userName: 'alice',
});
// → send registration.response to your server for verifyRegistration()
// Per-action: confirm + tap
const action = { action: 'send-message', data: { to: 'bob', body: 'hello' } };
const confirmation = createConfirmation(action);
// confirmation.code → "A7X3"
// Show in your UI: "You're sending a message to bob. Type A7X3 to confirm."
// After user types the code:
const proof = await requestTap({
challenge, // from your server (unique per action)
action,
confirmation,
userInput: 'A7X3', // what the user typed
allowCredentials: [{ id: registration.credentialId }],
rpID: 'example.com',
});
// → send proof to your server for verifyTapProof()For high-security deployments, restrict which authenticator models are accepted during registration. Each FIDO2 authenticator has an AAGUID — a UUID identifying its make and model.
// Only allow YubiKey 5 series (example AAGUIDs)
const result = await verifyRegistration({
response: registrationResponse,
expectedChallenge: challenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
allowedAAGUIDs: [
'cb69481e-8ff7-4039-93ec-0a2729a154a8', // YubiKey 5 NFC
'ee882879-721c-4913-9775-3dfcce97072a', // YubiKey 5Ci
],
});If the authenticator's AAGUID is not in the list, registration throws AAGUID_NOT_ALLOWED. When allowedAAGUIDs is omitted or empty, any authenticator is accepted.
The AAGUID is also stored on the TapCredential for auditing:
console.log(credential.aaguid); // "cb69481e-8ff7-4039-93ec-0a2729a154a8"Find AAGUIDs for specific hardware keys in the FIDO Alliance Metadata Service.
The 4-character confirmation code has ~20.68 bits of entropy (~1.7 million combinations). Without rate limiting, an attacker with a stolen key could brute-force the code.
You must rate-limit the verification endpoint. Example with express-rate-limit:
import rateLimit from 'express-rate-limit';
const verifyLimiter = rateLimit({
windowMs: 60_000, // 1 minute
max: 5, // 5 attempts per window
keyGenerator: (req) => req.ip ?? 'unknown',
message: { error: 'Too many verification attempts' },
});
app.use('/api/verify', verifyLimiter);For production, consider:
- Per-credential rate limiting (not just per-IP)
- Exponential backoff after consecutive failures
- Alerting on repeated failures (possible stolen key)
- A human with physical access to a registered hardware key approved the action
- The human confirmed they understood the action (typed the correct confirmation code)
- The approval is cryptographically bound to the specific action payload
- The approval is one-time use (challenge nonce prevents replay)
- The hardware key is genuine (attestation verification by default)
| Limitation | Status | Future Solution |
|---|---|---|
| Blind tap — key has no display | Mitigated — confirmation code proves the user read the action details | txAuthSimple FIDO2 extension when display-equipped keys become mainstream |
| Compromised client (XSS) — could show wrong action | Mitigated — server re-derives action hash + confirmation code independently | CSP hardening guide |
| Software authenticator spoofing | Mitigated — allowedAAGUIDs restricts to known hardware models |
Attestation certificate chain validation |
| Safari UV flag in clamshell mode | Mitigated — independent UV flag check in verifyTapProof() |
N/A, already handled |
| Single-key single-factor | Configurable — userVerification: 'required' adds PIN/biometric |
Multi-key quorum (2-of-3) in future version |
| Confirmation code entropy | ~20.68 bits (36^4) — rate-limit attempts | Longer codes or richer character sets in future version |
- Generate challenges server-side with
createChallenge()fromhumankey/verify - Enforce short TTLs on challenges (60s or less)
- Delete challenges after single use (prevent replay)
- Rate-limit confirmation code attempts — the 4-character code has ~20.68 bits of entropy (~1.7M combinations)
- Pass
expectedActionfrom your server's copy — never trust client-provided action data - Store credential public keys securely
- Monitor signature counters for anomalies (counter going backwards = cloned key)
- Use
allowedAAGUIDsto restrict authenticator models in high-security environments
| Import | Environment | Contents |
|---|---|---|
humankey |
Browser | createConfirmation, requestTap, registerKey, isHumanKeySupported, hashAction, HumanKeyError |
humankey/verify |
Server (any JS runtime) | verifyTapProof, verifyRegistration, createChallenge, HumanKeyError |
humankey/express |
Server (Express) | createHumanKeyRouter, MemoryChallengeStore, ChallengeStore |
humankey/nextjs |
Server (Next.js App Router) | createHumanKeyHandlers, MemoryChallengeStore, ChallengeStore |
humankey/hono |
Server (Hono) | createHumanKeyApp, MemoryChallengeStore, ChallengeStore |
humankey/fastify |
Server (Fastify) | humanKeyPlugin, MemoryChallengeStore, ChallengeStore |
humankey/react |
Browser (React) | useHumanKey |
| Code | Thrown by | Meaning |
|---|---|---|
CONFIRMATION_MISMATCH |
verifyTapProof |
User typed wrong confirmation code |
ACTION_HASH_MISMATCH |
verifyTapProof |
Client signed a different action than expected |
VERIFICATION_FAILED |
verifyTapProof |
WebAuthn signature invalid |
COUNTER_REPLAY |
verifyTapProof |
Counter didn't increase (possible cloned key) |
USER_VERIFICATION_MISSING |
verifyTapProof |
UV required but authenticator didn't verify user |
AAGUID_NOT_ALLOWED |
verifyRegistration |
Authenticator model not in allowlist |
REGISTRATION_FAILED |
verifyRegistration |
WebAuthn registration verification failed |
WEBAUTHN_NOT_SUPPORTED |
registerKey, requestTap |
Browser doesn't support WebAuthn |
USER_CANCELLED |
registerKey, requestTap |
User cancelled the WebAuthn prompt |
| Option | Default | Description |
|---|---|---|
requireUserVerification |
true |
Throw if the authenticator didn't verify the user (PIN/biometric) |
requireConfirmation |
true |
Throw CONFIRMATION_MISMATCH if the user typed the wrong code. Set false to check result.confirmationValid manually. |
| Option | Default | Description |
|---|---|---|
requireUserVerification |
true |
Throw if UV flag is not set |
allowedAAGUIDs |
undefined |
Array of allowed authenticator AAGUIDs. If set and non-empty, throws AAGUID_NOT_ALLOWED for unlisted models. |
humankey/
├── src/
│ ├── index.ts # Browser exports: createConfirmation, requestTap, registerKey, isHumanKeySupported
│ ├── confirm.ts # Confirmation code generation + validation
│ ├── tap.ts # WebAuthn assertion with action binding
│ ├── register.ts # One-time hardware key registration
│ ├── support.ts # Feature detection
│ ├── verify.ts # Server-side proof verification (humankey/verify)
│ ├── registration-verify.ts # Server-side registration verification
│ ├── challenge.ts # Server-side challenge generation
│ ├── adapter-core.ts # Shared handler logic for all framework adapters
│ ├── express.ts # Express framework adapter (humankey/express)
│ ├── nextjs.ts # Next.js App Router adapter (humankey/nextjs)
│ ├── hono.ts # Hono adapter (humankey/hono)
│ ├── fastify.ts # Fastify plugin (humankey/fastify)
│ ├── react.ts # React hook (humankey/react)
│ ├── hash.ts # SHA-256 canonical JSON hashing (isomorphic)
│ ├── types.ts # All type definitions
│ └── errors.ts # Typed error classes
├── tests/ # vitest test suite
│ ├── helpers/
│ │ └── soft-authenticator.ts # Software FIDO2 authenticator for integration tests
│ └── integration.test.ts # End-to-end tests against real @simplewebauthn/server
└── examples/basic/ # Working Express + HTML example
Seven entry points:
humankey— browser SDK (confirm + tap + register)humankey/verify— server-side verification, registration, and challenge generation (any JS runtime)humankey/express— Express router with built-in challenge lifecycle, registration, and verificationhumankey/nextjs— Next.js App Router route handlershumankey/hono— Hono app with humankey routeshumankey/fastify— Fastify pluginhumankey/react— React hook for the confirm → tap flow
- Language: TypeScript (strict mode)
- Build: tsup (dual ESM/CJS output)
- Test: vitest
- Core dependency: @simplewebauthn/browser (peer) + @simplewebauthn/server (verify)
- Target runtimes: Browser (client SDK), Node/Deno/Bun/Edge (verify utility)
npm install # install dependencies
npm run build # build ESM + CJS
npm run test # run tests
npm run typecheck # type check
npm run dev # watch modecd examples/basic
npm install
npm start
# → open http://localhost:3000cd demo
npm install
npm run dev
# → open http://localhost:3000A Next.js app that walks through the full humankey flow: register a hardware key → fill in a send-money form → confirm with code + key tap.
Hardware key PIN behavior: Your hardware key (YubiKey, etc.) has its own PIN that lives on the physical device. This PIN is always required during registration and verification — it's the key's own security layer and is completely independent of humankey. The first time you use a key, the browser will ask you to set a PIN. On subsequent uses, it will ask you to enter it. The "Clear server credentials" button in the demo only removes the server's record of your key — it cannot clear the PIN from the physical hardware. This is expected behavior.
Next.js note: The demo uses globalThis to persist in-memory challenge and credential state across Next.js hot module reloads. Without this, the MemoryChallengeStore would be re-created between API requests in dev mode, causing "Challenge not found or expired" errors. See demo/lib/humankey-config.ts for the pattern. In production, use a persistent store (Redis, database) instead.
New features:
- Express adapter (
humankey/express) —createHumanKeyRouter()provides a complete Express router with challenge lifecycle, registration, and verification. IncludesMemoryChallengeStore(in-memory, single-use, TTL-based) and aChallengeStoreinterface for custom backends (Redis, etc.). - Attestation allowlist —
allowedAAGUIDsoption onverifyRegistration()restricts accepted authenticator models by AAGUID.TapCredentialnow includesaaguidfield. - Integration tests — end-to-end tests using a software FIDO2 authenticator against real
@simplewebauthn/server(no mocks). Covers full registration, tap flow, confirmation mismatch, action tampering, and counter replay. - Counter replay detection —
verifyTapProof()now correctly returnsCOUNTER_REPLAYerror code when the authenticator counter doesn't increase.
Breaking changes:
TapCredentialnow includes a requiredaaguid: stringfield. Existing stored credentials need this field added (use'00000000-0000-0000-0000-000000000000'as a default for credentials registered before this version).
registerKey()now returnsRegistrationResult(withcredentialId,response,transports) instead of{ credential, response }. UseverifyRegistration()server-side to get the fullTapCredential.verifyTapProof()now throwsCONFIRMATION_MISMATCHby default when the confirmation code is wrong. PassrequireConfirmation: falsefor the old behavior.- Confirmation code derivation uses 16-bit values instead of single bytes, eliminating modulo bias. Codes for the same action will differ from previous versions.
- Error messages no longer leak expected/actual confirmation code values.
MIT