n8n Agent Approval Payload Hash: Bind the Yes to the Bytes

An n8n agent approval payload hash binds the reviewer's yes to the exact bytes: the gateway publishes only a payload whose SHA-256 was approved, and refused a regenerated draft in a scripted test.
On 5 November 2026 at 16:29:54 UTC, the publisher stand-in in my n8n 2.42.7 sandbox answered HTTP 201 to a post nobody had approved. The reviewer had said yes to a text that read "5 tries". The body that went out read "10 tries". My earlier n8n post has a Limitations note on approval and the exact payload, and it ends: "I have not built or tested that." This post builds an n8n agent approval payload hash gate and tests it.
TL;DR: In n8n 2.42.7 the Approve link signs the execution id, the node id and approved=true, not the payload, so every review email in one execution carries the same link. An n8n agent approval payload hash closes that gap: freeze the payload, hash its canonical JSON, publish only a hash a human approved. The agent in my test was scripted.
Can an n8n agent approval publish text nobody approved?
Yes, in n8n 2.42.7, when the agent retries inside one execution. The Approve link signs the execution id, the node id and approved=true, not the payload. Both review emails in that execution carried a byte-identical link, and a click on email 1's link published email 2's text with HTTP 201.
Three parts of the test were stand-ins: the agent, the click and the publisher. The agent was a mock LLM that speaks the OpenAI Chat Completions API and returns fixed tool calls, with a fixed "regenerated" draft that differs by one number. The click was an HTTP GET with a desktop browser User-Agent, not a person. The publisher logged each POST body with its SHA-256 and answered 503 to the first requests, and it was not Postiz, whose schedulePostTool my earlier post gated. The run shows what n8n does with a second, different tool call. The run says nothing about how often a real model changes a payload.
The agent was an AI Agent node (3.1) whose publish_post tool, an HTTP Request Tool (4.2), sat behind n8n's own Email review (emailSendHitlTool 2.1). Reviewer mail went to Mailpit 1.27.11. My first click, with curl's default User-Agent, got HTTP 200 and 0 bytes: the execution stayed waiting because n8n ignores approval clicks from bot-looking User-Agents (sendAndWait/utils.js:303-306). A browser User-Agent approved. The publisher answered 503, the agent called the tool again with "10 tries", and n8n sent review 2. Then I clicked email 1's link again, as a reviewer might if nothing seemed to happen, or as a forwarded email or a mail scanner that presents as a browser might. I tested none of those; that list is inference.
STEP 3 publisher POST #1 -> 503 body sha256 00f8a2a9...2071b
STEP 4 email 1 and email 2 approve links byte-identical: True
STEP 5 publisher POST #2 -> 201 body sha256 bf81a0db...934c16
RESULT text in email 1 (what the reviewer approved) == text published with 201: False
-Retry On Fail in n8n 2.42.7 stopped at 5 tries and 5 s between them, ...
+Retry On Fail in n8n 2.42.7 stopped at 10 tries and 5 s between them, ...Both links were .../webhook-waiting/1/g-review?approved=true&signature=<hmac>. n8n's own execution record says status=success with two review runs, and nothing in it marks that the second was approved from the first email.
One thing did not go wrong. n8n's page says: "If approved, the tool executes with the AI-specified input." That held. The review stores originalInput and runs the gated tool with it on approval (processHitlResponses.js:69-73), so each approved call ran with the input of the call being waited on, and the second call was put up for review again with its new text shown. The gap is that a click does not say which review it answers.
n8n's Retry On Fail was not the source either. With it on, the 503 was retried inside the node, the second POST had the same SHA-256 (00f8a2a9...2071b) and one review email went out. The near copy came from the agent's retry.
How do you build an n8n agent approval payload hash gate?
Freeze the payload in a workflow the agent cannot edit, hash its canonical JSON, email the reviewer those exact bytes with a link that carries the hash, and let one gateway publish only a payload whose hash is marked approved. I built it as three workflows on n8n 2.42.7.
The gateway is the only step that calls the publisher. The agent's publish_post tool calls it with channel and text. A Freeze payload node sets type to "draft", so the agent cannot choose the publish mode. The hash covers channel, text and type. There is no separate URL field: the post's link is part of text. The Code nodes need NODE_FUNCTION_ALLOW_BUILTIN=crypto to load crypto. This is the function the Code nodes run, from guard/canonical.js:
function canonicalJson(v) {
if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']';
if (v !== null && typeof v === 'object') {
return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}';
}
return JSON.stringify(v);
}
function sha256Hex(s) { return crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex'); }It is a subset of RFC 8785, enough for a payload that holds only strings. The RFC says object properties "MUST be sorted recursively" and array order is kept, and the function does both. I added no Unicode normalisation, and RFC 8785 also keeps Unicode strings "as is", so strings that differ in bytes hash differently. The offline self-test:
00f8a2a9...2071b v1
00f8a2a9...2071b v1, keys reordered
bf81a0db...934c16 v2 (5 -> 10 tries)
758de4eb...ca2828 v1 with one combining accent
canonical: OKThe gateway posts the canonical string itself as the body, so the publisher's body hash equals the approved hash. For a hash not yet in the approvals table (an n8n Data Table, below), it records a pending row and emails the bytes, the hash and a link:
Approve exactly these bytes: http://127.0.0.1:<port>/webhook/c02-approve?sha256=00f8a2a9...2071b&sig=<hmac>sig is an HMAC-SHA256 over the hash with a per-run random key. Clicking moves the row from pending to approved and sends the frozen bytes. A different payload has a different hash, so no link approves it.
How do you know the gate refuses a changed payload?
Run the same scenario with the check on and with it removed, and require green, then red. With the guard on, 5 of 5 checks passed and the regenerated "10 tries" call never reached the publisher. With the check removed, 0 of 5 passed, the "10 tries" body reached the publisher with 201, and no review email was sent.
The guard-on run had six steps against a publisher that fails its first two POSTs: propose "5 tries" (F1), click email 1's link (F2), run again with "5 tries" then the regenerated text (F3), click the link again (F4), then send the identical "5 tries" twice more (F5, F6). Timestamps dropped:
PASS F1 nothing reaches the publisher before a human approves (0 POST(s))
F3 ... {"result": "NOT PUBLISHED: queued for human review, sha256=bf81a0db...934c16"}]
PASS F3 a regenerated payload never reaches the publisher (0 POST(s) with a hash nobody approved)
F4 approval execution 22 error: error='sha256 00f8a2a9...2071b is already approved; this link does nothing now [line 6]'
PASS F4 a reused approval link publishes nothing
PASS F5 the identical approved bytes are published (201 with sha256 00f8a2a9...2071b)
F6 ... NOT PUBLISHED: REFUSED, no approval for sha256=00f8a2a9...2071b (row status: published)
PASS F6 an approval is used once (0 new POST(s))
SUMMARY guard=on: 5 passed, 0 failedF2 only clicks; the other five steps each end in a check.
The negative control is the same gateway with one constant, const GUARD = "off". It ended SUMMARY guard=off: 0 passed, 5 failed, exit 1. F1 saw 3 POSTs: #1 and #2 returned 503, and #3 returned 201 with the bf81a0db...934c16 body.
The run with the check removed has two artefacts I do not count as findings. The Mark published step matched no row, so the tool returned The workflow did not return a response after a successful 201. And the scripted model treats any error text as a failure, so it called again up to its fixed limit of 3.
regress.sh runs the hash self-test, then requires the guard-on run to exit 0 and the guard-off run to exit 1. It printed regress: OK in 43.9 s, and passed again on a sandbox rebuilt from setup.sh (94 s in total). The quoted captures come from a fourth sandbox. The first three, between 16:13 and 16:28 UTC, were for finding the mechanism.
One rough edge I did not fix: when the frozen bytes fail to publish (F2) or a link is reused (F4), the reviewer's browser shows HTTP 500 {"message":"Error in workflow"}. The reason is only in the execution record.
Where does the approved hash live, and what does it miss?
The approved hash lives in an n8n Data Table row whose status moves pending, approved, published. The agent has no write path to it, but any workflow in the project does. The hash stops different bytes, and the status stops a repeat after a successful send. Neither says the text is safe.
The table has columns sha256, payload, status, requested_at, approved_at and published_at. The agent's only tool calls the gateway, so it has no write path to the table. Others do. n8n's Data Tables page says: "By default, data tables created within a project are accessible to all team members in that project." It also lists a /datatables API endpoint, and the API key in my sandbox had all scopes.
Single use comes from the status, not the hash. approved is set only from pending, publishing happens only from approved, and a published hash is refused (F6). After a failed send, the identical approved payload goes out again: F2 and F3 produced two 503s with the same hash, and F5 produced the 201.
There is no expiry and no reviewer identity. A hash proves "the same as the one reviewed", so if the reviewer approved a bad text, the gate publishes it faithfully. The reviewer's reading stays the control.
Limitations
Everything above comes from runs on 5 November 2026 against n8n 2.42.7. I did not check other versions.
- No real model: the agent was a mock LLM returning fixed tool calls.
- No MCP: the review was tested on an HTTP Request Tool. Gating Postiz's
schedulePostToolon an MCP Client Tool was not run. - No human click: Approve was a GET with a Chrome User-Agent. Who re-clicks an old email in practice, including a mail scanner that presents as a browser, is inference.
- Email review only. Slack, Chat and the other channels share
getSignedResumeUrlin source but were not run, and Chat may resume differently. - n8n's "Retry execution" from the editor or API, queue mode and an Error Trigger workflow were not tested.
- Postiz was not called. The publisher recorded bytes.
- Concurrency was not tested. Two gateway runs for one approved hash could both read
approvedbefore either writespublished, because the Data Table update is not a compare-and-set. That is inference from the design. - My approval link is a plain GET with no bot filter, unlike n8n's. A scanner could approve it, in theory. A confirm page with a POST would close that, and I did not build it.
More of this work is in the rest of the Self-Hosted AI field notes. A yes that names no payload approves whatever is waiting. If you run n8n's tool-level review, what does your reviewer see when the agent retries with different text, and where does your workflow keep the approved hash?
References
- n8n docs, Human-in-the-loop for tools: what the reviewer receives, and "If approved, the tool executes with the AI-specified input." Read 6 November 2026.
- RFC 8785, JSON Canonicalization Scheme (JCS): recursive property sorting, array order, strings kept as is. Read 6 November 2026.
- n8n docs, Data tables: project-level access and the
/datatablesAPI endpoint. Read 6 November 2026. - n8n 2.42.7 source, read in the
n8nio/n8n:2.42.7image:n8n-core/dist/execution-engine/node-execution-context/node-execution-context.js:174-190,n8n-core/dist/utils/signature-helpers.js:9-19,n8n-nodes-base/dist/utils/sendAndWait/utils.js:303-306, 408-413, 446,@n8n/n8n-nodes-langchain/dist/utils/agent-execution/processHitlResponses.js:69-73. - The demo itself, run 5 November 2026 in throwaway Docker containers: the workflows,
guard/canonical.js, the run scripts and captures00to08, the source for every result quoted above.