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

@@ -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!');