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 tohttp://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 toapi.openai.com. - Tool credentials. For an HTTP Request tool that calls Stripe, create a
Header Auth credential with name
Authorizationand valueBearer {{seekrit:STRIPE_KEY}}, and point the tool's URL athttp://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
- Import the
.envinto seekrit and delete it. Run compose throughseekrit run, or makeseekrit-runthe container's entrypoint. - Leave
N8N_BLOCK_ENV_ACCESS_IN_NODEat its default. - Run the proxy as a sidecar with a token bound to an environment holding only the keys workflows may use.
- Replace each OpenAI-compatible and Header Auth credential with a placeholder and a proxy URL, in fixed mode.
- Set
methodsandpathson every route a tool can reach. - Plant a honey token in the old
.envlocation 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.