seekrit
← all posts

Securing n8n AI agent workflows

How-to

A self-hosted n8n instance is a credential store with a workflow engine attached. Every API key a workflow uses is saved as an n8n credential, encrypted in the database with N8N_ENCRYPTION_KEY, and handed to nodes at run time. The AI Agent node makes that store more interesting than it used to be: an agent with an HTTP Request tool and a Slack tool decides at run time which credentials to use, based on text that arrived in a webhook, an email, or a document it was asked to summarize.

Two things follow. The compose file that runs n8n holds the keys that protect everything else: the encryption key, the database password, and usually the SMTP and OAuth secrets too. And the credentials inside n8n are real values, usable for anything the upstream permits, by any node the agent decides to run. Independent research has already shown a read-only project viewer instructing an AI Agent node to run arbitrary nodes with the project's credentials. This post covers both layers.

Layer 1: the keys in the compose file

The typical docker-compose.yml for n8n reads a .env beside it:

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    environment:
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      DB_TYPE: postgresdb
      DB_POSTGRESDB_PASSWORD: ${DB_POSTGRESDB_PASSWORD}

N8N_ENCRYPTION_KEY is the one that matters most. Lose it and every stored credential is unreadable; leak it with a database dump and every stored credential is readable. If you do not set it, n8n generates one and writes it to ~/.n8n/config on the host volume, which is worse, because now it lives in a file nobody remembers exists.

Move the .env into a seekrit environment and delete the file:

seekrit secrets import .env --app n8n --env production
seekrit token create --app n8n --env production --name n8n-host
rm .env

Then either run compose through seekrit, which supplies the ${VAR} values from the child environment:

SEEKRIT_TOKEN=skt_… seekrit run -- docker compose up -d

or put the resolver inside the image, so the container fetches and decrypts its own environment at start and the compose file carries one variable. The n8n image's entrypoint is tini -- /docker-entrypoint.sh, so the wrapper goes in front of that:

FROM seekritdev/run:latest AS seekrit
FROM docker.n8n.io/n8nio/n8n
COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run
ENTRYPOINT ["seekrit-run", "--", "tini", "--", "/docker-entrypoint.sh"]
services:
  n8n:
    build: .
    environment:
      SEEKRIT_TOKEN: ${SEEKRIT_TOKEN}
      DB_TYPE: postgresdb

docker compose config and docker inspect now show a token bound to one environment, revocable from the dashboard, instead of the encryption key. The Docker Compose post covers the differences between the two shapes, including using a Compose secrets: mount so the token is not in the container's environment either.

One consequence to know about: with the entrypoint shape, every value in the seekrit environment is a process environment variable inside the n8n container. n8n's Code node can read process environment variables through $env, unless N8N_BLOCK_ENV_ACCESS_IN_NODE is true. It defaults to true since n8n 2.0. Leave it there. An AI Agent node with a Code tool and $env access is an agent that can read the encryption key.

Layer 2: the credentials inside n8n

n8n's answer to "keep credentials in a vault" is the external secrets feature, which loads values from AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, HashiCorp Vault, 1Password, or Infisical at run time. It is an Enterprise feature, and it still delivers a real value to the node: the vault holds the key instead of the n8n database, but the workflow gets the key either way, and so does the agent.

The seekrit approach works on the community edition and changes what the node gets. Run seekrit-proxy beside n8n, and store placeholders as n8n credentials:

seekrit proxy compose --preset openai --host api.stripe.com=STRIPE_KEY > compose.proxy.yaml
# merged into docker-compose.yml
services:
  seekrit-proxy:
    image: seekritdev/proxy
    environment:
      SEEKRIT_TOKEN: ${SEEKRIT_PROXY_TOKEN}
    volumes:
      - ./seekrit-proxy.toml:/seekrit-proxy.toml
    command: ["--listen", "0.0.0.0:8080"]

Then, in n8n:

  • OpenAI credential. Set the API key field to {{seekrit:OPENAI_API_KEY}} and the Base URL to http://seekrit-proxy:8080/openai/v1. Every OpenAI Chat Model node that uses this credential now sends a placeholder to the proxy, which swaps in the key on the way to api.openai.com.
  • Tool credentials. For an HTTP Request tool that calls Stripe, create a Header Auth credential with name Authorization and value Bearer {{seekrit:STRIPE_KEY}}, and point the tool's URL at http://seekrit-proxy:8080/stripe/v1/charges.

Keep those fields in n8n's fixed mode, not expression mode. n8n's own template syntax is also {{ }}, and a field switched to expression mode would try to evaluate seekrit:OPENAI_API_KEY as JavaScript. In fixed mode the string is passed through literally, which is what the proxy expects.

Now the n8n database holds placeholders. A database dump, an exported workflow with credentials, or an agent that was told to print its Stripe key yields the string {{seekrit:STRIPE_KEY}}, which works only through the proxy, only toward api.stripe.com, and only for the methods and paths the proxy's rule allows:

[[route]]
prefix = "/stripe"
upstream = "https://api.stripe.com"
allow = ["STRIPE_KEY"]
methods = ["GET"]
paths = ["/v1/charges", "/v1/charges/*"]

The agent's Stripe tool can look up a charge. If a webhook payload persuades it to issue a refund, the POST is refused at the proxy with a message naming the rule, and the refusal is logged. The proxy holds its own token, bound to an environment that contains only the keys workflows are allowed to spend, and the n8n container has no path to it.

What this does not fix

Placeholders in credentials protect the value. They do not stop an agent from doing something permitted with a permitted credential, and they do not fix n8n's own authorization model. If a project viewer can run an AI Agent that uses the project's credentials, the proxy will still substitute for that agent, because the request looks like every other request. Restrict who can edit and run workflows, keep the editor off the public internet, and update n8n when it ships security fixes.

The proxy also only helps for HTTP. A Postgres node needs a real connection string, and for that the answer is a temporary-access lease: a database credential minted for one run that expires after it.

Checklist

  1. Import the .env into seekrit and delete it. Run compose through seekrit run, or make seekrit-run the container's entrypoint.
  2. Leave N8N_BLOCK_ENV_ACCESS_IN_NODE at its default.
  3. Run the proxy as a sidecar with a token bound to an environment holding only the keys workflows may use.
  4. Replace each OpenAI-compatible and Header Auth credential with a placeholder and a proxy URL, in fixed mode.
  5. Set methods and paths on every route a tool can reach.
  6. Plant a honey token in the old .env location and in any exported workflow JSON that used to carry real values.

The agent proxy guide has the full config reference, and the Docker Compose post has the three ways to get the token itself into a container.