Skip to content

security: generate the Engine API JWT per deployment - #521

Merged
OisinKyne merged 2 commits into
main-v1.11from
security/per-deployment-jwt-v1.11
Nov 10, 2026
Merged

OisinKyne merged 2 commits into
main-v1.11from
security/per-deployment-jwt-v1.11

Conversation

@OisinKyne

Copy link
Copy Markdown
Contributor

Backport of the main PR (security/per-deployment-jwt) to main-v1.11. The added and removed lines are identical to the main PR; only the surrounding image versions differ, so it was re-applied rather than cherry-picked (the cherry-pick conflicts on that context).

Problem

jwt/jwt.hex was tracked in this repo, so every deployment used the same, public Engine API secret. The Engine API port (8551) is published on 127.0.0.1 by default and is reachable by every container on the compose network, so anyone who can reach it could drive the execution client.

Change

  • A one-shot jwt-init service (busybox, no network) generates 32 random bytes from /dev/urandom as 64 hex characters, with no 0x and no newline, into a jwt Docker volume. It writes to a temporary file and renames it, and never replaces an existing secret.
  • Every execution and consensus client service (nethermind, lighthouse, el-nethermind, el-reth, cl-grandine, cl-lighthouse, cl-teku, cl-lodestar, cl-prysm, cl-nimbus) wait for it (depends_on: service_completed_successfully) and mount the volume read-only at the path they used before.
  • The secret is in a named volume rather than ./jwt, so no host user can read it without root. Inside the volume it is 0444 root:root: Teku and Nimbus run as UID 1000 and the other clients as root, so no single owner or mode below world-readable works without changing users, and only these clients mount the volume.
  • An operator-provided jwt/jwt.hex (for an execution client outside the stack) is loaded into the volume instead. It must be one line of 64 hex characters, otherwise jwt-init fails and the clients do not start. The old public secret is ignored if it is left there.
  • jwt/jwt.hex is deleted from git and ignored. README and CLAUDE.md updated.

Upgrade behaviour

  • git pull and docker compose up -d, as documented. The changed mount makes compose recreate the execution and consensus clients, and both start with the new secret. The secret is rotated on purpose.
  • Between git pull and docker compose up -d the old containers keep running on the old secret, which they hold in memory. If one of them restarts in that window it finds no file: Nethermind, Reth and Lighthouse then write their own, the others exit. The pair recovers on docker compose up -d, and a file written this way is loaded like an operator-provided one.
  • git pull refuses to run if jwt/jwt.hex was changed locally. The README says what to do.
  • Anything outside the stack that used the old secret (an external consensus or execution client) must be given the new one: docker compose run --rm jwt-init cat /jwt/jwt.hex.

jwt/jwt.hex was tracked, so every deployment shared one public Engine
API secret. A one-shot jwt-init service now generates a random secret
in a jwt volume that only the execution and consensus clients mount,
read-only. An operator-provided jwt/jwt.hex is loaded instead, and the
old public secret is ignored. Upgrading recreates both clients with
the new secret.
The README covered what the secret is, upgrading, a refused git pull,
using your own secret and printing it in one paragraph. Each is now its
own point.
@sonarqubecloud

Copy link
Copy Markdown

@OisinKyne
OisinKyne merged commit 890af0f into main-v1.11 Nov 10, 2026
4 checks passed
@OisinKyne
OisinKyne deleted the security/per-deployment-jwt-v1.11 branch November 10, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant