fix: remove JWT_SECRET env var — server manages it exclusively

Setting JWT_SECRET via environment variable was broken by design:
the admin panel rotation updates the in-memory binding and persists
the new value to data/.jwt_secret, but an env var would silently
override it on the next restart, reverting the rotation.

The server now always loads JWT_SECRET from data/.jwt_secret
(auto-generating it on first start), making the file the single
source of truth. Rotation is handled exclusively through the admin
panel.

- config.ts: drop process.env.JWT_SECRET fallback and
  JWT_SECRET_IS_GENERATED export; always read from / write to
  data/.jwt_secret
- index.ts: remove the now-obsolete JWT_SECRET startup warning
- .env.example, docker-compose.yml, README: remove JWT_SECRET entries
- Helm chart: remove JWT_SECRET from secretEnv, secret.yaml, and
  deployment.yaml; rename generateJwtSecret → generateEncryptionKey
  and update NOTES.txt and README accordingly
This commit is contained in:
jubnl
2026-04-01 06:38:38 +02:00
parent 6f5550dc50
commit e10f6bf9af
10 changed files with 52 additions and 65 deletions

View File

@@ -136,7 +136,6 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable.
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
@@ -246,7 +245,6 @@ trek.yourdomain.com {
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `JWT_SECRET` | JWT signing secret; auto-generated and saved to `data/` if not set | Auto-generated |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC); auto-generated and saved to `data/` if not set. **Upgrading:** set to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials to migrate | Auto-generated |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |

View File

@@ -14,7 +14,6 @@ This is a minimal Helm chart for deploying the TREK app.
```sh
helm install trek ./chart \
--set secretEnv.JWT_SECRET=your_jwt_secret \
--set ingress.enabled=true \
--set ingress.hosts[0].host=yourdomain.com
```
@@ -29,6 +28,6 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed.
- `JWT_SECRET` should be set for production use; auto-generated and persisted to the data PVC if not provided.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Auto-generated and persisted to the data PVC if not provided. **Upgrading:** if a previous version used `JWT_SECRET`-derived encryption, set `secretEnv.ENCRYPTION_KEY` to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials via the admin panel.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.

View File

@@ -1,18 +1,24 @@
1. Secret handling (JWT_SECRET and ENCRYPTION_KEY):
- By default, the chart creates a Kubernetes Secret from the values in `secretEnv.JWT_SECRET` and `secretEnv.ENCRYPTION_KEY`.
- To generate random values for both at install (preserved across upgrades), set `generateJwtSecret: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must contain a key matching `existingSecretKey` (defaults to `JWT_SECRET`) and optionally an `ENCRYPTION_KEY` key. If `ENCRYPTION_KEY` is absent from the external secret, the server auto-generates and persists it to the data volume.
1. ENCRYPTION_KEY handling:
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
- If left empty, the server auto-generates and persists the key to the data PVC — safe as long as
the PVC persists.
- Upgrading from a version that used JWT_SECRET for encryption: set `secretEnv.ENCRYPTION_KEY` to
your old JWT_SECRET value, then re-save credentials via the admin panel.
2. ENCRYPTION_KEY notes:
- Encrypts stored API keys, MFA secrets, SMTP password, and OIDC client secret at rest.
- If left empty, auto-generated by the server and saved to the data PVC — safe as long as the PVC persists.
- Upgrading from a version that used JWT_SECRET for encryption: set `secretEnv.ENCRYPTION_KEY` to your old JWT_SECRET value to keep existing encrypted data readable, then re-save credentials via the admin panel.
2. JWT_SECRET is managed entirely by the server:
- Auto-generated on first start and persisted to the data PVC (data/.jwt_secret).
- Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret).
- No Helm configuration needed or supported.
3. Example usage:
- Set explicit secrets: `--set secretEnv.JWT_SECRET=your_secret --set secretEnv.ENCRYPTION_KEY=your_enc_key`
- Generate random secrets: `--set generateJwtSecret=true`
- Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key`
- Generate a random key at install: `--set generateEncryptionKey=true`
- Use an existing secret: `--set existingSecret=my-k8s-secret`
- Use a custom key for JWT in existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_JWT_KEY`
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
4. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
If using `existingSecret`, ensure the referenced secret and keys exist in the target namespace.
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.

View File

@@ -36,16 +36,11 @@ spec:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: ENCRYPTION_KEY
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
optional: true
volumeMounts:
- name: data

View File

@@ -1,4 +1,4 @@
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
apiVersion: v1
kind: Secret
metadata:
@@ -7,11 +7,10 @@ metadata:
app: {{ include "trek.name" . }}
type: Opaque
data:
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
ENCRYPTION_KEY: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
apiVersion: v1
@@ -23,10 +22,8 @@ metadata:
type: Opaque
stringData:
{{- if and $existingSecret $existingSecret.data }}
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "JWT_SECRET") | b64dec }}
ENCRYPTION_KEY: {{ index $existingSecret.data "ENCRYPTION_KEY" | b64dec }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
{{- else }}
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
ENCRYPTION_KEY: {{ randAlphaNum 32 }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
{{- end }}
{{- end }}

View File

@@ -19,10 +19,10 @@ env:
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# Secret environment variables stored in a Kubernetes Secret
# Secret environment variables stored in a Kubernetes Secret.
# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC,
# rotatable via the admin panel) — it is not configured here.
secretEnv:
# JWT signing secret. Auto-generated and persisted to the data PVC if not set.
JWT_SECRET: ""
# At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.).
# Auto-generated and persisted to the data PVC if not set.
# Upgrading from a version that used JWT_SECRET for encryption: set this to your
@@ -30,13 +30,12 @@ secretEnv:
# credentials via the admin panel and rotate to a fresh random key.
ENCRYPTION_KEY: ""
# If true, random values for JWT_SECRET and ENCRYPTION_KEY are generated at install
# and preserved across upgrades (overrides secretEnv values)
generateJwtSecret: false
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
# If set, use an existing Kubernetes secret for JWT_SECRET (and optionally ENCRYPTION_KEY)
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
existingSecret: ""
existingSecretKey: JWT_SECRET
existingSecretKey: ENCRYPTION_KEY
persistence:
enabled: true

View File

@@ -18,7 +18,6 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable.
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details

View File

@@ -1,9 +1,9 @@
PORT=3001 # Port to run the server on
NODE_ENV=development # development = development mode; production = production mode
JWT_SECRET=your-super-secret-jwt-key-change-in-production # Auto-generated if not set; persist across restarts for stable sessions
# ENCRYPTION_KEY=<random-256-bit-hex> # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.)
# Auto-generated and persisted to ./data/.encryption_key if not set.
# Upgrade: set to your old JWT_SECRET value if you have existing encrypted data from a previous installation.
# Upgrade from a version that used JWT_SECRET for encryption: set to your old JWT_SECRET value so
# existing encrypted data remains readable, then re-save credentials via the admin panel.
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details

View File

@@ -2,29 +2,28 @@ import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
let _jwtSecret: string = process.env.JWT_SECRET || '';
const dataDir = path.resolve(__dirname, '../data');
if (!_jwtSecret) {
const dataDir = path.resolve(__dirname, '../data');
const secretFile = path.join(dataDir, '.jwt_secret');
// JWT_SECRET is always managed by the server — auto-generated on first start and
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
// via environment variable (env var would override a rotation on next restart).
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
let _jwtSecret: string;
try {
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
} catch {
_jwtSecret = crypto.randomBytes(32).toString('hex');
try {
_jwtSecret = fs.readFileSync(secretFile, 'utf8').trim();
} catch {
_jwtSecret = crypto.randomBytes(32).toString('hex');
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(secretFile, _jwtSecret, { mode: 0o600 });
console.log('Generated and saved JWT secret to', secretFile);
} catch (writeErr: unknown) {
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn('Sessions will reset on server restart. Set JWT_SECRET env var for persistent sessions.');
}
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
console.log('Generated and saved JWT secret to', jwtSecretFile);
} catch (writeErr: unknown) {
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn('Sessions will reset on server restart.');
}
}
const JWT_SECRET_IS_GENERATED = !process.env.JWT_SECRET;
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
export let JWT_SECRET = _jwtSecret;
@@ -48,7 +47,6 @@ export function updateJwtSecret(newSecret: string): void {
let ENCRYPTION_KEY: string = process.env.ENCRYPTION_KEY || '';
if (!ENCRYPTION_KEY) {
const dataDir = path.resolve(__dirname, '../data');
const keyFile = path.join(dataDir, '.encryption_key');
try {
@@ -66,4 +64,4 @@ if (!ENCRYPTION_KEY) {
}
}
export { JWT_SECRET_IS_GENERATED, ENCRYPTION_KEY };
export { ENCRYPTION_KEY };

View File

@@ -1,5 +1,4 @@
import 'dotenv/config';
import { JWT_SECRET_IS_GENERATED } from './config';
import express, { Request, Response, NextFunction } from 'express';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import cors from 'cors';
@@ -268,9 +267,6 @@ const server = app.listen(PORT, () => {
'──────────────────────────────────────',
];
banner.forEach(l => console.log(l));
if (JWT_SECRET_IS_GENERATED) {
sLogWarn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.');
}
if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED');
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');