Your agent has an approval gate. Good. It pauses before it wires money, waits for a human to click Approve, and only then proceeds. Now assume the agent has been prompt-injected — a poisoned web page, a malicious tool result, a crafted document. The attacker is now executing inside the same process that draws your confirm dialog and reads the click. It can approve its own payment. The gate held the door for the honest agent and swung it open for the dishonest one.
Here's the short version, up front: a software approval gate trusts the code asking for approval; a hardware approval does not. If an action is irreversible — money, a production change, privileged access, a signature — put a physical key press between the agent and the action, using the same WebAuthn primitive behind passkeys. The signing key lives on a device the agent's runtime cannot reach, so a compromised agent can prepare the action but cannot sign it. This is the pattern YubiKey 5.8's "verified authorization" is standardizing; you can build the core of it today.
Why the software gate isn't the whole answer#
Software human-in-the-loop is genuinely useful, and you should keep it. A LangGraph interrupt or an AI SDK tool-approval prompt stops the common failure: an agent that is wrong or over-eager, pausing so a human can catch it. What it does not stop is an agent whose client is compromised, because the confirmation and the approval live in the same trust domain the attacker now controls.
The guardrail that holds is the one the agent physically cannot cross.
The move is to relocate the "yes" to hardware. WebAuthn already does exactly this for login; we're going to use it for one action instead of one session.
Step 1 — Gate the irreversible action, not everything#
Reads and drafts run at full speed. Only a short allowlist of action types demands a key press. Deny-by-default on that list; everything else passes through.
const HARDWARE_APPROVAL = new Set([
"payments.transfer",
"infra.deploy_prod",
"iam.grant_privileged",
"contracts.sign",
]);
async function runToolCall(call, ctx) {
if (HARDWARE_APPROVAL.has(call.name)) {
return requireHardwareApproval(call, ctx); // step-up below
}
return execute(call); // reversible work, no friction
}
Step 2 — Mint a per-action challenge, bound to the action#
When a gated action arrives, create a pending-action record and issue a WebAuthn authentication challenge tied to it. The point is that the signature the key returns must be inseparable from this action — an old approval can never be replayed onto a new one.
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { createHash, randomUUID } from "node:crypto";
async function requireHardwareApproval(call, ctx) {
const action = { id: randomUUID(), name: call.name, args: call.args, actor: ctx.user.id };
const digest = createHash("sha256")
.update(JSON.stringify({ name: action.name, args: action.args }))
.digest();
const options = await generateAuthenticationOptions({
rpID: "yourapp.com",
userVerification: "required", // force a fresh, verified touch
allowCredentials: ctx.user.credentials, // this human's registered keys
// bind the action into the challenge so the signature covers it
challenge: Buffer.concat([randomUUID.bytes ?? Buffer.alloc(0), digest]),
});
await db.pendingActions.put(action.id, {
action, challenge: options.challenge, expiresAt: Date.now() + 90_000,
});
// send options + a human-readable summary of `action` to the browser
return { status: "awaiting_hardware_approval", actionId: action.id, options, summary: describe(action) };
}
userVerification: "required" is the load-bearing flag: it forces the authenticator to confirm a present, verified human (PIN or biometric plus touch) for this challenge, not reuse a cached state.
Step 3 — The human touches the key#
In the browser, show the action in plain language, then hand the options to the authenticator. Nothing here holds a secret; the private key stays on the device.
import { startAuthentication } from "@simplewebauthn/browser";
// after the user reads "Approve transfer of $5,000 to acct_1842?"
const assertion = await startAuthentication({ optionsJSON: options });
await fetch(`/actions/${actionId}/approve`, {
method: "POST",
body: JSON.stringify(assertion),
});
Step 4 — Verify server-side, then execute#
The server verifies the assertion against the stored challenge for that pending action, checks the signature counter, and only then runs the tool. If anything about the action changed, the challenge won't resolve and the action dies.
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
app.post("/actions/:id/approve", async (req, res) => {
const pending = await db.pendingActions.get(req.params.id);
if (!pending || pending.expiresAt < Date.now()) return res.status(410).end();
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: pending.challenge, // the action is inside this
expectedOrigin: "https://yourapp.com",
expectedRPID: "yourapp.com",
credential: lookupCredential(req.body.id),
requireUserVerification: true,
});
if (!verification.verified) return res.status(403).end();
await db.pendingActions.delete(req.params.id); // single use
const result = await execute(pending.action); // now, and only now
res.json({ ok: true, result });
});
An attacker who owns the agent can reach Step 2 — it can ask for approval. It cannot pass Step 4, because the signature it needs is produced by a key on a device in someone's pocket, and it never sees the private half.
The honest limit, and where it's going#
With standard WebAuthn today, the human reads the action in the browser and the key press attests "a verified human was present for this exact challenge, right now." A truly compromised client could still misrepresent the action text on screen. Closing that last gap means displaying the action on the hardware itself — which is precisely what YubiKey 5.8's verified authorization and the emerging WebAuthn signing extension add via CTAP 2.3. Until those are broadly deployable, three habits get you most of the guarantee: keep the challenge single-use, keep the TTL short (90 seconds, not an hour), and require userVerification so every touch is fresh.
This is the authorization half of agent security. The identity half — proving who the agent is and cutting each credential to least privilege — is the companion move; see how to scope an agent's permissions and how to give an agent a short-lived, scoped credential. Authenticate who it is, scope what it may touch, and put a hardware key in front of the actions you can't take back.



