"n8n encrypts your credentials" is true, and it is also the point where most people stop asking questions. Encrypted with what? Where is that key stored? What happens to a restored backup that does not have it? The docs answer each of those in a sentence, but I wanted to see the behaviour myself before relying on it for someone else's API keys.
So today (October 5, 2026) I ran a throwaway lab: n8n 2.41.7 in Docker, a fake credential, a local echo server, and the n8n CLI. Everything in this post that says "I ran" is output from that lab, pasted from the terminal with the fake values left in. Everything that says "the docs say" comes from pages I fetched today and I link them. One thing I could not test is external secret stores, because they are an Enterprise feature, and I say so where it matters.
Short answer: n8n stores every credential encrypted in its database, using one instance-wide encryption key. If you do not set the key yourself, n8n generates one and writes it to a config file in the same folder as the database. Set N8N_ENCRYPTION_KEY (or the _FILE variant) yourself, back the key up separately from the data, and never paste a secret into a node field.
The lab setup
Nothing fancy. The official image, a bind-mounted data folder, and the CLI commands that ship inside the container. I used fake values everywhere: the "API token" is sk-FAKE-lab-token-1234567890 and the encryption keys were generated with openssl rand -hex 32 and thrown away afterwards.
$ docker run --rm n8nio/n8n:latest --version
2.41.7
n8n 2.x changed several security defaults compared with the 1.x posts you will still find on the web. I will point at each one where it bites. If you are on an older version, check the 2.0 breaking changes page before copying anything below.
Where n8n keeps a credential
I imported one fake credential through the CLI with the key supplied as an environment variable, then opened the SQLite file directly and read the row:
('labCred00000001', 'Lab Header Auth', 'httpHeaderAuth',
'U2FsdGVkX1+DjKX5wfBDndimo2CHoZODk405pXHF4NKX8Puizc1/wZs4fau7i0lg5IiFJk', 128)
The data column is not JSON. It is a base64 string that starts with U2FsdGVk. I base64-decoded the first characters and got the eight bytes Salted__, which is the header OpenSSL-style AES encryption puts in front of the salt. The plaintext header, name and Bearer token are nowhere in that column. The credential's name and type are stored in clear in their own columns, so anyone with the database can see that you have a "Stripe live key" credential, just not its value.
The export command shows the same split. By default it prints ciphertext. Add --decrypted and it prints the secret:
# default export: still ciphertext
"data":"U2FsdGVkX1+DjKX5wfBDndimo2CHoZODk405pXHF4NKX8Puizc1/wZs4fau7i0lg5IiFJk5QwBJI24m6BxAVS81p+Jr+GnzrYIUkliXuO…"
# export --decrypted: plaintext
"data":{"name":"Authorization","value":"Bearer sk-FAKE-lab-token-1234567890"}
That second form is the one to be careful with. More on that in the leaks section.
The key: what happens when you do not set one
My first run had no N8N_ENCRYPTION_KEY at all. I started the container against an empty folder and listed the result:
$ ls -la data1
-rw------- config
-rw-r--r-- database.sqlite
$ cat data1/config
{
"encryptionKey": "6RWe…[rest redacted]"
}
$ wc -c data1/config
56 data1/config
n8n generated a key and wrote it to config, mode 0600, inside the same folder as database.sqlite. The encryption key docs say the same thing: n8n creates a random key on first launch and saves it in the ~/.n8n folder.
Two consequences I did not appreciate until I saw the files side by side:
- A backup of the data folder contains both the lock and the key. If your backup job tars the whole
/home/node/.n8nvolume, the ciphertext and the key that decrypts it travel together. Whoever can read that archive can read every credential. The encryption protects you from someone who copies only the database, not from someone who copies the folder. - Setting the key by environment variable does not remove the file. I started a fresh folder with
N8N_ENCRYPTION_KEYset and n8n still wrote aconfigfile. I compared itsencryptionKeyto the value I passed in: they matched. The environment variable seeds the file when no key is stored yet. So "I use an env var" does not mean "the key is not on the disk".
The docs add one more rule worth repeating: in queue mode you must specify the encryption key environment variable for every worker, so the workers can decrypt what the main instance encrypted.
What a key mismatch looks like
I ran two failure cases against a copy of the data. In the first, the config file was present and I passed a different key in the environment. In the second, I deleted config and passed a different key.
# wrong N8N_ENCRYPTION_KEY, config file still present
Error: Failed to load command "export:credentials"
Error: Mismatching encryption keys. The encryption key in the settings file /home/node/.n8n/config does not match the N8N_ENCRYPTION_KEY env var. Please make sure both keys match.
# config file deleted, so n8n has no stored key to compare against
Legacy aes-256-cbc encryption key not found; falling back to instance key
Error exporting credentials. See log messages for details.
Credentials could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.
The first case fails loudly at startup and tells you which two things disagree. That is the friendly one. The second is the dangerous one: with no stored key to compare against, n8n happily starts, and the first time it tries to use a credential it cannot decrypt it, with an error that says "a different encryptionKey was used". Your workflows import fine, the canvas looks normal, and every node that needs a credential breaks.
I also ran a third case, with no environment variable and no config file. n8n generated a brand new key and wrote it to a new config. The same decrypt error followed. That is the "restored the database but lost the key" situation. I found no way to read the values without the original key, so the practical outcome is that you re-enter every credential by hand. This is why the self-host guide I wrote earlier says to store the key somewhere safe and never change it: losing it costs you every credential in the instance.
Setting the key without putting it in an environment variable
Plain environment variables leak in more places than people expect: docker inspect, process listings, crash reports, a pasted compose file. n8n supports a _FILE suffix on most variables so the value is read from a file instead. The configuration methods page says you can append _FILE to individual variables and that this makes it possible to load data from Docker secrets and Kubernetes secrets.
I wrote the smallest compose file I could that uses it, with the key in a file next to the compose file:
services:
n8n:
image: n8nio/n8n:latest
ports:
- "127.0.0.1:5679:5678"
environment:
- N8N_ENCRYPTION_KEY_FILE=/run/secrets/n8n_encryption_key
- N8N_DIAGNOSTICS_ENABLED=false
secrets:
- n8n_encryption_key
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
secrets:
n8n_encryption_key:
file: ./secrets/n8n_encryption_key
Then I brought it up for real and checked four things:
$ openssl rand -hex 32 | tr -d '\n' > secrets/n8n_encryption_key && chmod 600 secrets/n8n_encryption_key
$ docker compose config -q && echo "compose config: valid"
compose config: valid
$ docker compose up -d
healthz: 200 after ~8s
$ docker compose exec n8n sh -c 'env | grep -c "^N8N_ENCRYPTION_KEY="'
0
$ docker compose exec n8n stat -c "%a %n" /home/node/.n8n/config
600 /home/node/.n8n/config
config key == secret file: True
It came up healthy in about eight seconds. Inside the container, N8N_ENCRYPTION_KEY is not set in the environment (the count is 0), so docker inspect will not show it. The secret file is mounted at /run/secrets/ with mode 600. n8n read it and wrote the same key into its own config, also mode 600. That last mode matches the 2.0 breaking-changes page, which says n8n now requires 0600 permissions on configuration files by default, "similar to how SSH protects private keys".
What this does not fix: the key still ends up in config inside the n8n_data volume. The _FILE approach keeps the key out of your compose file, your shell history and your process environment. It does not keep it out of a volume snapshot. Treat the volume as secret material, encrypt backups of it, and keep a second copy of the key in a password manager so that a restore never depends on the volume alone.
How a workflow uses a credential
A node never holds the secret. It holds a reference. I built a two-node workflow with an HTTP Request node set to "Generic Credential Type, Header Auth", pointing at my fake credential, and called a local echo server that prints the Authorization header it receives. Then I executed it from the CLI.
# default settings, workflow reads {{ $env.LAB_SECRET }}
Execution error:
access to env vars denied
NodeOperationError: access to env vars denied
# the HTTP node with the stored credential, seen from the echo server
SERVER SAW Authorization: Bearer sk-FAKE-lab-token-1234567890
# same workflow, N8N_BLOCK_ENV_ACCESS_IN_NODE=false
"fromEnv": "hunter2-lab"
"status": "success",
The line to look at is the middle one: the server saw the real Bearer token. n8n decrypted the credential at run time and attached it to the request. Then I exported the workflow and printed the credentials block of each node:
exec 1 error | has env secret: False | has bearer token: False
exec 2 success | has env secret: True | has bearer token: False
# credentials block of the exported workflow (nodes without one are null)
[null, {"httpHeaderAuth": {"id": "labCred00000001", "name": "Lab Header Auth"}}, null]
token in workflow export: False
The workflow JSON carries only {"id": ..., "name": ...} for the credential. The token string is not in the export. That is the property that makes workflows safe to commit to git, post in a forum, or hand to a client: as long as the secret lives in a credential and not in a node parameter.
It also tells you what to expect when you hand a workflow JSON to someone else: the node points at a credential ID that will not exist on their instance, so they have to pick or create their own. The HTTP Request node tutorial walks through the node itself.
Where secrets leak anyway
Encryption at rest is the part n8n handles for you. The leaks I see in real workflows are the ones around it. I tested four.
1. Hard-coded values in node parameters
If you type Bearer sk-... into a header field on the HTTP Request node, or put ?api_key=... on the URL, it is a plain node parameter. It is saved in the workflow JSON, so it appears in every export and in every git commit of that export. The execution table also has a workflowData column (I listed the columns), so I would expect a copy there too, though I did not check. The credential system never sees the value.
I wrote a small scanner for this and ran it on a clean export and on a deliberately bad one. It walks every node's parameters, flags values that look like secrets (Bearer tokens, sk- prefixes, api_key= query strings, long hex strings), and flags literal values sitting next to a parameter named like Authorization or X-Api-Key. It skips values that are n8n expressions.
import json, re, sys
SUSPECT_KEYS = re.compile(r"(authorization|api[-_]?key|apikey|token|secret|password|x-api-key)", re.I)
SUSPECT_VALUES = re.compile(r"(Bearer\s+\S{8,}|sk-[A-Za-z0-9_-]{8,}|[?&](api_?key|token|key)=[^&\s]{6,}|[A-Fa-f0-9]{32,})")
def walk(node, path=""):
if isinstance(node, dict):
for k, v in node.items():
yield from walk(v, f"{path}.{k}" if path else k)
elif isinstance(node, list):
for i, v in enumerate(node):
yield from walk(v, f"{path}[{i}]")
elif isinstance(node, str):
yield path, node
def scan(workflow):
hits = []
for n in workflow.get("nodes", []):
pairs = list(walk(n.get("parameters", {})))
for path, val in pairs:
if val.startswith("={{") and "$credentials" in val:
continue
if SUSPECT_VALUES.search(val):
hits.append((n["name"], path, "value looks like a secret"))
# header/query parameter lists: name in one string, value in the next
for path, val in pairs:
if path.endswith(".name") and SUSPECT_KEYS.search(val):
vpath = path[:-4] + "value"
v = dict(pairs).get(vpath, "")
if v and not v.startswith("={{"):
hits.append((n["name"], vpath, f"literal value next to '{val}'"))
return hits
if __name__ == "__main__":
bad = 0
for f in sys.argv[1:]:
data = json.load(open(f))
for wf in (data if isinstance(data, list) else [data]):
for node, path, why in scan(wf):
print(f"{f}: node '{node}' {path}: {why}")
bad += 1
sys.exit(1 if bad else 0)
$ python3 scan_workflow_secrets.py wf_clean.json; echo "exit=$?"
exit=0
$ python3 scan_workflow_secrets.py wf_hardcoded.json; echo "exit=$?"
wf_hardcoded.json: node 'Call API' url: value looks like a secret
wf_hardcoded.json: node 'Call API' headerParameters.parameters[0].value: value looks like a secret
wf_hardcoded.json: node 'Call API' headerParameters.parameters[0].value: literal value next to 'Authorization'
exit=1
It exits 1 when it finds something, so you can run it in a git pre-commit hook or a CI step over your exported workflows. It is deliberately noisy: the one hard-coded header got reported twice, once by the value pattern and once by the name-next-to-value rule. It is a smoke detector, not a proof. It will miss a secret split across two fields and it will flag the odd 32-character hex ID. I would rather review five false alarms than miss one live key in a public repo.
2. Reading secrets from environment variables inside a workflow
A common shortcut is to put a key in the container environment and read it with {{ $env.MY_KEY }} in a node. In my first run on 2.41.7, with no relevant settings, that failed:
The error was access to env vars denied, shown in the output two sections up. This is deliberate. The 2.0 breaking changes page says n8n now blocks environment variable access from the Code node by default, and that the default of N8N_BLOCK_ENV_ACCESS_IN_NODE is now true. Its migration advice is to set it to false only if you need the old behaviour, and "for sensitive data, use credentials or other secure methods instead of environment variables".
One thing to know if you cross-check against the docs: the security environment variables reference, fetched today, still lists the default of that variable as false. The breaking-changes page and my own run both say true. I trust the run, and I would check your version rather than either page.
Then I set the variable to false and ran the workflow again. The Set node returned the secret as fromEnv, and I looked at what n8n had saved. The execution database holds a row per run, and the row for the successful run contained the string hunter2-lab. The row for the run that used the stored credential did not contain the Bearer token. So the credential path keeps the secret out of execution history, and the $env path writes it into execution history in plaintext. If you have to enable env access, turn on "do not save successful executions" for those workflows, or better, move the value into a credential.
3. The decrypted export file
When I ran export:credentials --all --decrypted --output=plain.json, the resulting file was created with mode -rw-r--r--. Any other user on that machine could read it. I deleted it with shred straight after use. If you do a rotation like the one below, run it with a restrictive umask 077 and delete the file as soon as the import finishes.
4. Credential values inside Code nodes and logs
I did not run this one, so treat it as advice, not a result: a Code node that logs $input.all() after an HTTP call can print whatever the upstream API echoes back, including tokens in redirect URLs. The error handling guide covers what error workflows capture; read it before you send execution data to a Slack channel.
Rotating the encryption key
There is no "change key" button. n8n encrypts each credential with the instance key, so rotation means decrypting everything with the old key and re-encrypting with the new one. The CLI can do both halves. I tested the full round trip on a fresh instance, not on a live one:
$ n8n export:credentials --all --decrypted --output=plain.json # OLD key
Successfully exported 1 credentials.
$ ls -la plain.json
-rw-r--r-- 1 suyashraj suyashraj 379 plain.json
$ n8n import:credentials --input=plain.json # fresh instance, NEW key via _FILE
Successfully imported 1 credential.
config key == new key: True
ciphertext differs after rotation: True
$ n8n export:credentials --all --decrypted # NEW key
"data":{"name":"Authorization","value":"Bearer sk-FAKE-lab-token-1234567890"}
$ n8n export:credentials --all --decrypted # OLD key against new data
Error: Mismatching encryption keys. The encryption key in the settings file /home/node/.n8n/config does not match the N8N_ENCRYPTION_KEY env var.
The order that worked:
- Back up the data folder and the current key, separately.
- With the old key set, run
export:credentials --all --decryptedto a file readable only by you. - Start a fresh instance with the new key (I used
N8N_ENCRYPTION_KEY_FILE) and runimport:credentialson that file. - Check that the ciphertext in the new database differs from the old one, and that the new key decrypts it. In my run both were true, and the old key against the new data failed with the mismatch error.
- Delete the plaintext export.
What I did not test: a live instance with existing workflows and execution history, OAuth2 credentials whose refresh tokens are rotated by the provider, and queue-mode workers, which each need the new key. For OAuth2 I would expect to re-authorise rather than assume the refresh token survives, and I would not rotate during business hours.
One opinion, labelled as mine: rotating the n8n key does not help if the old key and database have already leaked together. The values inside were exposed. In that case the real fix is to revoke and reissue each API key at the provider, then store the new ones. Rotation protects you going forward, it does not un-leak the past.
External secret stores
If you want secrets to live in a vault and not in n8n's database at all, n8n has a feature for it. I could not test it: the external secrets page states, as of today, that it is available on Enterprise for both Cloud and self-hosted.
What the page says, which I verified in the raw HTML and not through a summary:
- Supported providers: 1Password (via Connect Server), AWS Secrets Manager, Azure Key Vault, GCP Secrets Manager, HashiCorp Vault, and Infisical. HashiCorp Vault Secrets is not supported.
- Secrets are referenced in a credential field with an expression that starts
{{ $secrets.<vault-name>... }}. - They resolve only in credential fields, not in other fields that accept expressions. That closes the "read it into a Set node" leak by design.
- Secret values must be plaintext, not JSON objects.
- From n8n 2.10.0 you can connect several vaults per provider, and vaults can be scoped globally or to a single project.
On the Community edition, which is what most self-hosters run, this is not available, and the realistic setup is the one above: a _FILE key, credentials in n8n's database, and encrypted backups.
The options side by side
| Approach | Where the secret sits | Reaches execution history? | My status |
|---|---|---|---|
| n8n credential (default) | Encrypted in the database, key in config |
No (checked) | Ran it |
N8N_ENCRYPTION_KEY_FILE + compose secret |
Key in a 600-mode file, copied into config |
No | Ran it |
$env.X in a node |
Container environment | Yes, in plaintext (checked) | Ran it, blocked by default |
| Typed into a node field | Workflow JSON | In every export (execution copy not checked) | Scanner flags it |
| External secrets vault | Your vault, resolved in credential fields only | Not tested | Docs only, Enterprise |
My checklist for a new instance
This is the order I would follow on a new instance, and every item maps to something above:
- Generate a 64-character hex key with
openssl rand -hex 32before the first start. Store a copy in a password manager. - Pass it as
N8N_ENCRYPTION_KEY_FILEfrom a mode-600 file, never as a plain variable in a committed compose file. - Leave
N8N_BLOCK_ENV_ACCESS_IN_NODEat its 2.x default and use credentials for everything secret. - Export workflows to git, and run the scanner over every export before it is committed.
- Encrypt volume backups, and keep the key's backup somewhere other than the volume's backup.
- Do a rotation drill on a scratch instance once, so the first time is not during an incident.
For where this fits in a self-hosted stack, the Docker setup guide covers the container, volume and update steps, and the n8n automation hub lists the rest of the series. If you are weighing whether a workflow is worth the maintenance at all, the ROI calculator is a quick sanity check, and the AI readiness assessment covers the wider picture. For data-handling policy beyond n8n itself, I wrote up the security and privacy basics for automation separately.
What I would do differently
The measured result that matters from this lab: the credential path left no trace of the token in execution history or in the workflow export, while the two shortcuts (an $env read and a typed header) did. Everything else is housekeeping around that.
If I were starting over, I would set the encryption key explicitly before the very first launch, so the auto-generated config file is never the only copy. And I would run the scanner over workflow exports from day one, not after the first leak, because it takes a minute to run and the cost of a key in a public repo is a day of revoking and reissuing.
The scanner is a smoke detector with known gaps, the rotation was tested on a fresh instance only, and external secret stores are untested here. If you try any of it on a different n8n version and get different output, the version number at the top of this post is the first thing to compare.
If you want a second pair of eyes on an n8n setup that already holds production keys, that is part of what I do on the consulting page.
Frequently Asked Questions
Where does n8n store credentials?
In its database, encrypted with one instance-wide key. In my test the credential data column held a base64 string beginning with Salted__ once decoded, while the name and type were stored in clear. The key comes from N8N_ENCRYPTION_KEY, or n8n generates one and saves it in a config file in the same data folder.
What happens if I lose the n8n encryption key?
The stored credentials cannot be decrypted. In my test, starting with a different key or with the config file deleted produced the error that credentials could not be decrypted because a different encryptionKey was used. Workflows still import, but every node that needs a credential fails until you re-enter the values.
Can I keep secrets in environment variables in n8n?
You can load the encryption key from an environment variable or a _FILE variable, but reading secrets with $env inside nodes is blocked by default in n8n 2.x. When I enabled it, the value was saved in plaintext in the execution data, which a stored credential did not do. Use credentials for anything secret.
How do I rotate the n8n encryption key?
Export credentials with the old key using export:credentials --all --decrypted, start a fresh instance with the new key, and run import:credentials on that file, then delete the plaintext export. I tested this round trip on a fresh instance only. If the old key and database leaked together, also revoke and reissue the keys at each provider.
Are n8n external secrets available on the free version?
No. The n8n docs page, checked October 5, 2026, lists external secrets as an Enterprise feature on both Cloud and self-hosted. Supported providers include AWS Secrets Manager, Azure Key Vault, GCP Secrets Manager, HashiCorp Vault, Infisical and 1Password via Connect Server.
Ready to Get Started?
Book a free 30-minute discovery call. We'll identify your biggest opportunities and show you exactly what AI automation can do for your business.