Skip to main content

Local JWT signing keys (ES256)

Applies to deployments running with KEYCLOAK_ENABLED=false, which is every deployment today.

Why this changed

Tokens used to be signed with HS256 over LOCAL_JWT_SECRET. One secret both signs and verifies, and every replica needs it in order to check a token, so every replica can also mint one. If that value leaks, the holder can issue a valid access token for any account, super_admin included, and nothing in the platform can tell the difference.

ES256 splits the two. The private key signs and stays with the issuer; the public key verifies and is published at GET /api/v1/auth/jwks.json. A verifier — another module, the cloud tier, a gateway — needs nothing secret at all.

Generate a key pair

openssl ecparam -name prime256v1 -genkey -noout -out ec.key
openssl pkcs8 -topk8 -nocrypt -in ec.key -out ec-private-pkcs8.pem # LOCAL_JWT_PRIVATE_KEY
openssl ec -in ec.key -pubout -out ec-public.pem # LOCAL_JWT_PUBLIC_KEY
rm ec.key

P-256 (prime256v1) specifically — that is the curve ES256 names, and the only one the signer builds.

Paste either the PEM block or its bare base64; armour and whitespace are stripped, because environment variables and Helm values mangle newlines routinely.

The private key is a secret and belongs in the environment or a secrets manager, exactly as the PII keys do. Never in a values file that is committed. .env.example ships it blank for the same reason LOCAL_JWT_SECRET is blank: a key printed in a repository is not a key.

Losing the private key invalidates every live session — nobody can verify the tokens already issued. That is recoverable (users sign in again); the reverse, a leaked key, is not.

Settings

VariableMeaning
LOCAL_JWT_PRIVATE_KEYPKCS#8 P-256 private key. Set it and signing moves to ES256.
LOCAL_JWT_PUBLIC_KEYX.509 P-256 public key. Published at the JWKS endpoint.
LOCAL_JWT_KEY_IDThe kid stamped on tokens and published on the JWK. Defaults to local-es256.
LOCAL_JWT_ACCEPT_LEGACY_HS256Whether HS256 tokens are still accepted. Defaults to true.
LOCAL_JWT_SECRETThe legacy HS256 secret. Still verifies while the transition is open.

Both key variables must be set; one alone leaves the deployment on HS256.

Cutting over without a flag day

  1. Generate the pair and set LOCAL_JWT_PRIVATE_KEY / LOCAL_JWT_PUBLIC_KEY. Leave LOCAL_JWT_ACCEPT_LEGACY_HS256=true and leave LOCAL_JWT_SECRET in place.
  2. Restart. New tokens are ES256; the startup log says so and names the kid. Access tokens already in browsers are HS256 and keep working for their remaining TTL, and their refresh chain keeps working too, so nobody is signed out.
  3. Wait out LOCAL_JWT_REFRESH_TOKEN_TTL — 7 days by default. After that no HS256 session can still be alive, because the longest-lived thing that could renew one has expired.
  4. Set LOCAL_JWT_ACCEPT_LEGACY_HS256=false and restart. Now remove LOCAL_JWT_SECRET.

⚠⚠ Step 4 is the step that is easy to skip, and skipping it means the ticket bought nothing. While the transition is open the old secret still verifies, so it is still worth stealing: anyone holding it can still mint a token the platform accepts. Steps 1 to 3 stop new tokens depending on it; only step 4 stops old ones being honoured.

⚠ Setting LOCAL_JWT_ACCEPT_LEGACY_HS256=false with no key pair configured refuses to start, on purpose. That combination can neither issue nor accept any token, and a deployment that started in it would fail every single login in a way that reads like a credential fault.

Rotating the key

Rotation is the same shape, using the kid:

  1. Generate a second pair, set it as the new LOCAL_JWT_PRIVATE_KEY/PUBLIC_KEY with a new LOCAL_JWT_KEY_ID, and restart. New tokens carry the new kid.
  2. Tokens signed with the previous key stop verifying immediately, so rotate at a quiet moment or accept that everyone signs in again — the access-token TTL is 15 minutes.

⚠ The kid is set by an operator and never derived from the key material, so the two keys in circulation can be named. A kid computed from the key would change on rotation in a way nobody chose.

The JWKS endpoint

GET /api/v1/auth/jwks.json — anonymous by design, cacheable for 10 minutes.

⚠ It publishes only the public JWK. The private scalar (d) is never in the response, and a test asserts that: serving it would hand every anonymous reader the ability to mint tokens, which is strictly worse than the symmetric secret this replaced, because it would be worse and public.

An empty keys array is the correct answer on a deployment still signing with HS256 — there is no public key to publish yet.