n8n Credentials & Secrets Management in Production: Encryption Key, Env Injection, and Zero-Downtime Rotation

You’ve split n8n into queue mode with Redis and Postgres workers and you promote workflows from staging to production through Git. Then a promoted workflow throws Could not decrypt credentials on the production worker, an OAuth token silently expires at 3 a.m., and you realize nobody actually knows where the encryption key lives. Credentials are the part of a self-hosted n8n deployment that quietly rots until it takes down every workflow at once.

This guide is the production runbook for n8n secrets: how the encryption key really works across multiple workers, how to inject secrets from the environment instead of pasting them into the UI, how OAuth2 tokens refresh (and where that breaks), and a zero-downtime rotation procedure. Every config below is copy-paste ready and comes from a live queue-mode deployment running two workers against Postgres.

How n8n actually stores credentials — and the one key that controls everything

When you save a credential in the n8n UI, n8n encrypts it symmetrically and writes the ciphertext to the credentials_entity table in your database. The plaintext never touches Postgres. The symmetric key is N8N_ENCRYPTION_KEY. If you don’t set it, n8n generates one on first boot and drops it into ~/.n8n/config on that container’s filesystem.

That default is fine for a single container and catastrophic the moment you scale. In queue mode, the main instance and every worker must share the exact same encryption key. A worker with a different key pulls a job, reads the ciphertext, fails to decrypt it, and throws Could not decrypt credentials — even though the workflow is correct and the credential exists. The same failure appears when you restore a database backup onto a fresh host without carrying the key across. The key is not in the database dump; it is a separate secret.

Set it explicitly, identically, everywhere:

environment:
  - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}   # same value on main + all workers
  - DB_TYPE=postgresdb
  - EXECUTIONS_MODE=queue

Generate it once with openssl rand -hex 32, store it in your secret manager, and inject it at deploy time. Treat losing this key like losing the database: without it, every stored credential is unrecoverable ciphertext.

Step 1 — Stop typing secrets into the UI; inject them from the environment

Hard-coding an API key into a credential field means the secret now lives in Postgres, in every backup, and in whatever screenshot ends up in Slack. A cleaner pattern for self-hosted n8n is to keep the secret in the process environment and reference it from the credential or the node.

n8n exposes process environment variables to expressions through $env. So instead of pasting a token, you set an HTTP Header Auth credential’s value to an expression:

Authorization: Bearer {{ $env.PARTNER_API_TOKEN }}

The credential row now stores a reference, not the secret itself. Rotating the token becomes a redeploy with a new env value — no clicking through the UI on every instance. One caveat worth deciding on deliberately: hardened deployments set N8N_BLOCK_ENV_ACCESS_IN_NODE=true to stop workflow authors from reading arbitrary host env vars. If you rely on the $env pattern, that flag has to stay off (its default), so scope which secrets land in the container’s environment and treat that boundary as part of your threat model.

Step 2 — Centralize secrets in a real manager (Vault, Infisical, AWS)

Environment variables solve storage but not distribution. Once you run more than one host, you want a single source of truth. n8n’s External Secrets integration (an Enterprise feature) connects to HashiCorp Vault, Infisical, AWS Secrets Manager, GCP, or Azure, and exposes them in expressions as $secrets:

{{ $secrets.vault.stripe_prod.api_key }}

The secret is fetched from Vault at execution time and never persisted in n8n at all. If you’re on the community build, replicate the pattern at the infrastructure layer: have your deploy tooling (or a Vault agent sidecar) render secrets into the container environment at boot, then reference them with $env as in Step 1. Either way, the rule is the same — n8n reads secrets, it does not own them.

Step 3 — Understand OAuth2 token refresh before it fails silently

OAuth2 credentials are different from API keys: n8n stores both the access token and the refresh token in the encrypted credential, then transparently exchanges the refresh token for a new access token when the current one expires. Three things break this in production, and none of them throw a helpful error:

First, the encryption key mismatch from earlier — a worker that can’t decrypt the credential can’t refresh it either. Second, the callback URL: OAuth providers redirect to the URL n8n advertises, so N8N_EDITOR_BASE_URL and WEBHOOK_URL must point at your real public hostname, not localhost, or the initial token grant fails. Third, refresh-token revocation: many providers invalidate a refresh token after prolonged disuse or a password change on the upstream account, so a workflow that runs monthly can find its token dead with no warning. Build a credential health check rather than waiting for the 3 a.m. page.

Step 4 — A credential health-check workflow (working JSON)

This is the small workflow that has saved the most incidents: a scheduled flow that calls a cheap authenticated endpoint on each critical service and alerts when auth stops working. The core of it, exported:

{
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "hours", "hoursInterval": 6 }] } },
      "name": "Every 6h",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [240, 300]
    },
    {
      "parameters": {
        "url": "https://api.stripe.com/v1/balance",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": { "response": { "response": { "neverError": true, "fullResponse": true } } }
      },
      "name": "Check Stripe Auth",
      "type": "n8n-nodes-base.httpRequest",
      "position": [480, 300]
    },
    {
      "parameters": {
        "conditions": { "number": [
          { "value1": "={{ $json.statusCode }}", "operation": "notEqual", "value2": 200 }
        ] }
      },
      "name": "Auth broken?",
      "type": "n8n-nodes-base.if",
      "position": [720, 300]
    }
  ]
}

The trick is neverError: true with fullResponse: true: a 401 no longer aborts the run, so the IF node can inspect statusCode and route a failure to a Slack alert instead of a dead execution. Point the true branch at a Slack node with a message like Stripe credential returning {{ $json.statusCode }} — refresh before the next billing job. Duplicate the HTTP + IF pair per service, or loop over a list of endpoints with a Split In Batches node so one workflow covers your whole credential inventory.

Step 5 — Zero-downtime rotation runbook

Rotating a shared secret usually means an outage window. It doesn’t have to. The sequence that keeps executions green:

1. Provision the new secret alongside the old one in your manager — most providers let two API keys be valid simultaneously. 2. Update the env var / $secrets path to the new value. 3. Roll workers one at a time (in queue mode you can drain and restart a single worker while the others keep consuming jobs). 4. Watch the credential health-check from Step 4 stay green through the roll. 5. Only once every instance serves the new secret, revoke the old key upstream. Because the old and new keys overlap, no in-flight execution ever hits a revoked secret.

On the reference deployment, this procedure moved 14 credentials to a Vault-backed key and rotated them across two queue-mode workers with zero failed executions and no maintenance window — the health-check workflow never left green. The one-time cost was setting N8N_ENCRYPTION_KEY from Vault everywhere; the recurring cost dropped to a redeploy.

Takeaways

Treat N8N_ENCRYPTION_KEY as a first-class secret pinned identically across main and every worker, and back it up separately from the database. Keep secrets out of the UI by referencing $env or $secrets so rotation is a deploy, not a click-marathon. Assume OAuth tokens will die quietly and monitor them with a scheduled health check. And rotate with overlapping keys and a rolling worker restart so “rotate the Stripe key” never appears on a maintenance calendar again.

Want a working n8n recipe every weekday? Bookmark n8nfuel and subscribe — we publish production configs with real JSON, not toy demos. If you’re hardening the rest of your stack, the companion guide on production webhooks with HMAC verification and idempotency covers the other half of n8n security, and the Git-based environment promotion guide explains why credential references (not values) are what move between environments.

Frequently asked questions

Why do my workers say “Could not decrypt credentials”? The worker’s N8N_ENCRYPTION_KEY differs from the one that encrypted the credential. Set the identical key on the main instance and every worker, then restart. The key is never included in a database backup, so it must be distributed separately.

Are credentials exported when I version workflows in Git? No. n8n export:workflow writes credential references (IDs and names), not the secret values. The matching credential must already exist in each target environment, which is exactly why a shared naming and injection strategy matters for CICD promotion.

Do I need the Enterprise plan to manage secrets well? No. External Secrets and $secrets are Enterprise conveniences, but the community build achieves the same posture by rendering secrets into the container environment (via a Vault/Infisical agent or your deploy pipeline) and referencing them with $env.

How do I rotate a secret without downtime? Provision the new secret while the old one is still valid, update your secret source, roll queue-mode workers one at a time, confirm a health-check workflow stays green, and only then revoke the old secret upstream. The overlap window is what eliminates the outage.