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
| Variable | Meaning |
|---|---|
LOCAL_JWT_PRIVATE_KEY | PKCS#8 P-256 private key. Set it and signing moves to ES256. |
LOCAL_JWT_PUBLIC_KEY | X.509 P-256 public key. Published at the JWKS endpoint. |
LOCAL_JWT_KEY_ID | The kid stamped on tokens and published on the JWK. Defaults to local-es256. |
LOCAL_JWT_ACCEPT_LEGACY_HS256 | Whether HS256 tokens are still accepted. Defaults to true. |
LOCAL_JWT_SECRET | The 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
- Generate the pair and set
LOCAL_JWT_PRIVATE_KEY/LOCAL_JWT_PUBLIC_KEY. LeaveLOCAL_JWT_ACCEPT_LEGACY_HS256=trueand leaveLOCAL_JWT_SECRETin place. - 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. - 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. - Set
LOCAL_JWT_ACCEPT_LEGACY_HS256=falseand restart. Now removeLOCAL_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:
- Generate a second pair, set it as the new
LOCAL_JWT_PRIVATE_KEY/PUBLIC_KEYwith a newLOCAL_JWT_KEY_ID, and restart. New tokens carry the newkid. - 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.