Signing secrets pinned to database

Secrets that previously changed whenever the encryption key rotated are now stable across boots—user sessions, webhook URLs, and binary-data links survive key rotation without interruption.
The problem was that four critical secrets—instance ID, HMAC signature key, JWT signing key, and binary-data signing key—were derived fresh from the encryption key every time n8n started. Change the encryption key, and all active user sessions vanished, in-flight webhook resumption URLs broke, shared links stopped working, and telemetry/licensing lost track of the instance.
A two-phase initialization pattern now decouples these secrets from the encryption key. On first boot, the derived value is persisted to a new deployment_key database table. Every subsequent boot reads from the database instead of deriving again, so the secrets remain stable regardless of what happens to the encryption key.
The pattern handles multi-main deployments safely. When two instances race to insert a secret, an INSERT OR IGNORE RETURNING * query silently handles the conflict—the losing instance reads the winner's value without exceptions or locks.
Environment variables take precedence when set, allowing operators to pin secrets externally. The upgrade path is seamless: derived values are identical to the old behavior, so existing sessions continue working on first boot of the new version.
View Original GitHub DescriptionFact Check
Summary
This PR delivers two Linear tickets (IAM-485 and IAM-486) as a single unit because the IAM-486 work is directly layered on top of IAM-485.
Problem
n8n derives four signing secrets directly from the encryption key at every boot:
| Secret | Used for | Derivation |
|---|---|---|
instanceId | Telemetry, licence | SHA256(encryptionKey.slice(mid)) |
hmacSignatureSecret | Webhook resumption URL signing | SHA256("hmac-signature:" + encryptionKey) |
jwtSecret | User session JWT signing | SHA256("n8n:" + encryptionKey) |
signingSecret | Binary-data presigned URL signing | SHA256("url-signing:" + encryptionKey) |
Rotating the encryption key immediately and silently invalidates all active user sessions, in-flight webhook resumption URLs, shared binary-data links, and instance identity used for telemetry/licensing.
Solution
A two-phase initialisation pattern that decouples each secret from the encryption key after first boot:
- Constructor — always derives the value from the encryption key (backward-compatible fallback; no change on upgrade).
initialize(repo)— called once after DB migrations complete. Precedence:env var → active DB row → insertOrIgnore (persist) → race-condition fallback read.
The derived value is persisted to the deployment_key table on first boot. Every subsequent boot reads from DB, so the secret is stable regardless of what happens to the encryption key.
Concurrency safety (multi-main)
DeploymentKeyRepository.insertOrIgnore() issues INSERT OR IGNORE RETURNING *. On a unique-index conflict (two mains racing at startup), the insert is silently dropped and null is returned; the caller reads the winner's row. No exceptions, no locks.
Changes
packages/@n8n/db (IAM-485)
- New entity
DeploymentKey—id,type,value,status,algorithm, timestamps - New repository
DeploymentKeyRepositorywithfindActiveByType()andinsertOrIgnore() - New migration
1777000000000-CreateDeploymentKeyTable— table + unique partial indexes forinstance.id,signing.hmac,signing.jwt,signing.binary_data
packages/core / InstanceSettings (IAM-485 + IAM-486)
instanceIdpersisted todeployment_key(typeinstance.id) viainitialize(repo)hmacSignatureSecretmade mutable; persisted via sameinitialize(repo)call (typesigning.hmac)- Duck-typed inline repo interface avoids circular package dependency (
@n8n/db→n8n-coreat runtime)
packages/core / BinaryDataConfig (IAM-486)
- New
initialize(repo)— env-var fast-path → DB read → insertOrIgnore → race fallback; typesigning.binary_data
packages/cli / JwtService (IAM-486)
jwtSecretmadeprivate- New
initialize(repo)— same pattern; typesigning.jwt; respectsN8N_USER_MANAGEMENT_JWT_SECRET
packages/cli / start.ts (IAM-486)
- Calls
initialize(DeploymentKeyRepository)forInstanceSettings,JwtService, andBinaryDataConfigin sequence after DB migrations, before licence init
Tests
instance-settings.test.ts— 8 new tests (4×instance.id, 4×signing.hmac)jwt.service.test.ts— 4 newinitialize()testsbinary-data.config.test.ts— 4 newinitialize()tests
Each set covers: env-var fast-path, existing DB row, persist-on-first-boot, concurrent-insert race condition.
Upgrade path
No breaking changes. Derived values are identical to pre-upgrade; they are transparently persisted on first boot of the new version. Sessions, webhook URLs, and binary-data links continue to work without interruption.
Environment variables
| Variable | Behaviour |
|---|---|
N8N_ENCRYPTION_KEY | Still the derivation source on first boot |
N8N_USER_MANAGEMENT_JWT_SECRET | Pinned; DB write skipped entirely |
N8N_HMAC_SIGNATURE_SECRET | Pinned; DB write skipped entirely |
N8N_BINARY_DATA_SIGNING_SECRET | Pinned; DB write skipped entirely |
N8N_INSTANCE_ID | Pinned; DB write skipped entirely |
Related Linear tickets
https://linear.app/n8n/issue/IAM-485 https://linear.app/n8n/issue/IAM-486
Review / Merge checklist
- I have seen this code, I have run this code, and I take responsibility for this code.
- PR title and summary are descriptive. (conventions)
- Docs updated or follow-up ticket created.
- Tests included.
- PR labeled with
Backport to Beta,Backport to Stable, orBackport to v1if urgent.