Integrations n8n
n8n · ai agent node

Keep real keys off the n8n canvas.

n8n's AI Agent node (LangChain-based) calls Tool nodes to reach external APIs. The problem: n8n's built-in credential store is visible to anyone with workflow-edit access, and credentials stored there can surface in agent memory or tool output. The deniable pattern moves credential resolution off the canvas entirely — into a Code node sub-workflow or a thin resolver service — so the real key never appears in the workflow JSON, the n8n UI, or the agent's context window.

Jump to code ↓

Prerequisites

why not n8n's built-in credential vault?

n8n's credential store encrypts at rest but exposes the decrypted value to any node that references it, and to anyone with workflow-edit or owner-level access to the n8n instance. A prompt-injection attack that gets the AI Agent node to call a crafted tool can exfiltrate a credential from memory. deny.sh keeps the real key on your server; n8n never sees it.

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 value

Why this is the deniable shape

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. The deny.sh audit chain records every vault access with a tamper-evident receipt.

n8n's own credential vault still exposes the decrypted value to every node that references it and to anyone with workflow-edit access. The Code node pattern keeps the real key entirely off the canvas.

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 narrowed DTO. 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.0.4
// 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).

Multi-tenant 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-tenant 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

The five errors a launch-day developer hits when wiring deny.sh into an n8n AI Agent workflow. Click to expand the 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.0.4.

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 your internal route with no Authorization header (or a low-blast-radius internal token). 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 wrong vault password returns label_not_found, not invalid_password. That is deliberate: the wire shape is identical whether the label is absent or the password is wrong, so an attacker cannot enumerate which labels exist by probing passwords.

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-tenant 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