Keep real keys out of agent chat.
Let an n8n agent use a service without putting the key in its conversation. Resolve the key in trusted code and return only approved fields. Choose a Code node for execution inside n8n, or a separate resolver to keep the key outside n8n too.
Jump to code ↓Before you start
- n8n self-hosted (v1.x) with an AI Agent node configured. Cloud plans work the same way for the resolver-route path.
npm install deny-sh@^2.2.0on the host running your resolver service (or setNODE_FUNCTION_ALLOW_EXTERNAL=deny-shif using the Code node directly : see Troubleshooting).- A deny.sh account (free to start), its API key (service access credential) and a vault entry under it (see /dashboard/vault).
VAULT_PWandDENY_API_KEYset in your server or resolver environment : never pasted into n8n's credential store or exposed in a workflow node.
Separate model access from workflow-administrator access. A Code node can use the real key inside n8n, so an administrator able to change that code may expose it. A separate resolver service gives you a boundary outside n8n, provided it authenticates and authorises every request.
Path 1: Code node in a sub-workflow
Wire your AI Agent node's "Call n8n Workflow" tool to a sub-workflow whose first node is a Code node. The Code node resolves the vault entry, makes the privileged HTTP call, and returns a narrowed JSON. The real key never leaves the Code node's execution scope.
Show code : ~32 lines, javascript (Code node)
// n8n Code node (sub-workflow) : JavaScript (Node.js)
// NODE_FUNCTION_ALLOW_EXTERNAL=deny-sh must be set in n8n's environment,
// OR use the resolver-route approach (Path 2) to avoid the sandbox restriction.
const { vaultGet } = require('deny-sh/client');
// Input from AI Agent node via the "Call n8n Workflow" tool
const invoiceId = $input.first().json.invoice_id;
// Resolve the credential INSIDE this Code node boundary
const stripeKey = await vaultGet(
'stripe-prod',
process.env.VAULT_PW // set in n8n's environment variables
);
// Make the privileged call server-side
const response = await fetch(
`https://api.stripe.com/v1/invoices/${invoiceId}`,
{ headers: { Authorization: `Bearer ${stripeKey}` } }
);
const body = await response.json();
// Narrowed DTO: only return what the agent actually needs
return [{
json: {
id: body.id,
amount_due: body.amount_due,
status: body.status,
}
}];
// stripeKey is never included in the return valueWhy the key stays private
The Stripe key is resolved and consumed entirely inside the Code node's execution scope. The AI Agent node receives only { id, amount_due, status }. The key never appears in the workflow JSON visible in n8n's canvas, never enters any n8n credential store record, and never reaches the LLM's context window. Review recorded vault activity in the audit log; an audit record is not proof of every downstream use.
The Code node pattern keeps the key out of the model’s return value, not out of the n8n host. Protect workflow editing, execution logs and error handling.
Path 2: HTTP Request Tool pointing at your resolver route
If NODE_FUNCTION_ALLOW_EXTERNAL is unavailable (n8n Cloud, locked-down self-hosted), or you want a single credential resolution point across multiple workflows, run a small Express service alongside n8n. The AI Agent node's HTTP Request Tool calls your route; your route does the vault lookup server-side and returns the filtered response. The deny.sh key and VAULT_PW live only in your service's environment.
Show code : ~38 lines, typescript (Express resolver route)
// npm install express deny-sh@^2.2.0
// VAULT_PW and DENY_API_KEY are in this service's env : never in n8n
import express, { Request, Response } from 'express';
import { vaultGet } from 'deny-sh/client';
const app = express();
app.use(express.json());
// n8n AI Agent node → HTTP Request Tool → POST /internal/get-invoice
app.post('/internal/get-invoice', async (req: Request, res: Response) => {
const { invoice_id, tenant_id } = req.body;
if (!invoice_id) return res.status(400).json({ error: 'invoice_id required' });
// Multi-tenant: look up per-tenant vault password + API key from your store
const { tenantPw, tenantApiKey } = await getTenantCredentials(tenant_id);
// Resolve the real Stripe key inside this route boundary
const stripeKey = await vaultGet(
'stripe-prod',
tenantPw,
{ api_key: tenantApiKey } // per-tenant deny.sh API key
);
const r = await fetch(
`https://api.stripe.com/v1/invoices/${invoice_id}`,
{ headers: { Authorization: `Bearer ${stripeKey}` } }
);
const body = await r.json();
// Narrowed DTO: never forward raw upstream bodies to the agent
res.json({
id: body.id,
amount_due: body.amount_due,
status: body.status,
});
// stripeKey and full Stripe response body are not returned
});
app.listen(3100);HTTP Request Tool configuration in n8n
In the AI Agent node, add an HTTP Request Tool. Set Method to POST, URL to http://localhost:3100/internal/get-invoice (or your internal hostname), and Body Parameters to invoice_id (mapped from the agent's tool input). Do not add an Authorization header in the n8n Tool config : the resolver route is on your internal network and the real credential is resolved inside the route. If you need to authenticate the n8n-to-resolver call, use a shared internal token stored in n8n's credential store (low blast radius: it only gates this internal route, not the actual upstream API key).
Multiple customers resolver pattern
For SaaS platforms where each tenant has an isolated Stripe account, the resolver route takes a tenant_id, looks up that tenant's vault password and deny.sh API key from your database, and calls vaultGet with the per-customer credentials. Each tenant's vault entries are cryptographically isolated: a leak of one tenant's vault password compromises only that tenant's entries.
The n8n workflow passes tenant_id as a body parameter : either injected at the workflow level from a trigger payload, or set as a static value per workflow if each workflow belongs to one tenant. The resolver never falls back to a shared key; if getTenantCredentials throws, the route returns 403 and the agent receives an error, not a shared credential.
Troubleshooting
Common integration problems. Expand an item for the technical fix.
Cannot find module 'deny-sh' in the Code node
n8n's Code node runs in a sandboxed Node.js environment that blocks external npm packages by default. To allow deny-sh, set the environment variable NODE_FUNCTION_ALLOW_EXTERNAL=deny-sh on the n8n process (add it to your docker-compose.yml or systemd unit's Environment= block) and install the package in n8n's working directory: cd /path/to/n8n && npm install deny-sh@^2.2.0.
If you cannot modify n8n's environment (n8n Cloud or a locked-down instance), use the resolver-route approach (Path 2 above) instead. The Code node calls $http.post('http://your-resolver/internal/get-invoice', ...) : no external npm import required from the Code node side.
AI Agent node never calls the tool : it answers from its own knowledge instead
n8n's AI Agent node uses the tool's description field to decide whether to invoke it. If the description is vague ("get invoice"), the LLM may skip the tool and hallucinate an answer. Make the description operationally specific: "Retrieve a Stripe invoice by its id (e.g. in_1Abc123). Returns amount_due in cents and current status. Always use this tool when the user mentions an invoice id."
Also confirm the tool's input schema matches what the agent will emit. For the Code node path, the sub-workflow's input field name must match the parameter name the agent uses (invoice_id, not invoiceId). Mismatch causes the tool call to arrive with an empty body, which the Code node treats as a no-op.
HTTP Request Tool auth header leaking into agent memory
If you configure an Authorization header directly in n8n's HTTP Request Tool node (e.g. Bearer sk_live_... typed into the Headers field), that header value can appear in the agent's tool-call trace and, depending on your n8n version, in the execution log visible to workspace members.
Fix: use the resolver-route pattern (Path 2). The HTTP Request Tool in n8n calls an authenticated internal route with a narrowly scoped credential. Enforce the permitted customer and operation on the server; never trust a model-supplied customer identifier alone. The real Stripe key is resolved inside your Express route, never in the n8n canvas. The execution log then shows only POST /internal/get-invoice with a body of { invoice_id: "in_..." } : no secrets.
VAULT_PW or DENY_API_KEY missing on the resolver host
The resolver service and (if used) the n8n Code node both need VAULT_PW and DENY_API_KEY in their runtime environment. For Docker Compose, add them to the environment: block of your resolver service (not n8n's service). For a separate Node.js process, use a .env file (excluded from version control) or your platform's secret store.
Never store VAULT_PW or DENY_API_KEY in n8n's built-in credential store and reference them from a node : that negates the entire deniability pattern. These values belong only in the environment of the process that calls vaultGet.
label_not_found from vaultGet when the vault entry exists
A missing label may return vault_label_not_found. Do not assume a wrong vault password returns invalid_password. Check the label, account scope and password separately. The vault is not the core real-and-decoy scheme.
Fix: confirm the VAULT_PW value in your resolver's environment matches the password used when the label was stored via vault_store. For multi-tenant flows, also confirm the tenant_id lookup is returning the correct per-customer password : a fallback to a default or empty string will produce label_not_found on every call. If you have rotated the vault password, re-store every label under the new password; old ciphertexts cannot be decrypted with the new one.
Production checklist
- Set up /dashboard/decoy-alerts with a SHA-256 of a decoy
controlData. Receive configured alerts when our monitored decryption endpoint observes a registered fingerprint. An alert is not guaranteed to arrive before the response. - Wire /dashboard/webhooks to your existing Datadog / PagerDuty / Slack so vault-access anomalies surface alongside your n8n workflow alerts.
- Pull audit-chain receipts via
GET /v1/audit/receipt/<id>or browse them at /dashboard/audit for reviewable evidence of recorded activity of which workflow execution accessed which vault entry and when. - Use /dashboard/byok if your compliance policy requires customer-managed KMS keys for the vault encryption layer.
What to know before production.
A Code node keeps the key out of the model’s return value, not out of the n8n host. Protect workflow editing, execution logs and error handling. Use a separate resolver service if n8n must not receive the key.