Secrets & Rotation

This page lists the secrets a Poker Engine install holds, what happens to sessions and stored data when you change each one, and how to rotate them safely. The short version:

  • Back up ENCRYPTION_KEY outside the server. Without it, encrypted credentials and authenticator secrets can’t be recovered. Database backups don’t contain it.
  • Rotating ENCRYPTION_KEY doesn’t re-encrypt anything. Old records stay readable only while the old key is listed in ENCRYPTION_PREVIOUS_KEYS, and there’s no bulk re-encryption command.
  • Rotating JWT secrets doesn’t log everyone out. Refresh tokens aren’t derived from them.

Where Secrets Live

Install typeLocation
Docker Compose.env.prod in the install directory, passed to the app with env_file
HelmA Kubernetes Secret, either created by the chart from secrets.* values or supplied by you through secrets.existingSecret
BothPayment provider, SMTP, plugin and Developer API credentials are stored in the database, encrypted with ENCRYPTION_KEY. They’re managed in the Admin Panel and aren’t environment variables

The application reads secrets from its environment only. It has no built-in connector for an external secret manager. To use one, have it populate .env.prod or the Secret named in secrets.existingSecret.

support-bundle.sh redacts .env.prod before adding it to a support bundle.

Secrets Inventory

Startup validation rejects missing or malformed required values, and values containing placeholder text such as change-me or your-secret.

VariableRequirementUsed for
POSTGRES_PASSWORDRequired (Compose)PostgreSQL login. docker-compose.prod.yml builds DATABASE_URL from it
DATABASE_URLRequired (Helm, or if you run outside Compose)Database connection, including the password
REDIS_PASSWORDRequired (Compose)Redis login. docker-compose.prod.yml builds REDIS_URL from it
JWT_ACCESS_SECRETRequired, at least 64 charactersSigns access tokens
JWT_REFRESH_SECRETRequired, at least 64 charactersValidated at startup, but nothing currently signs with it. See below
JWT_2FA_TEMP_SECRETRequired, at least 64 charactersSigns the 5-minute token issued between password and 2FA steps
CSRF_SECRETRequired, at least 32 charactersCSRF token validation
ENCRYPTION_KEYRequired in production, exactly 64 hex charactersAES-256-GCM encryption of stored secrets
ENCRYPTION_PREVIOUS_KEYSOptional, up to 3 comma-separated 64-character hex keysOlder keys still accepted for decryption
ADMIN_INITIAL_PASSWORDRequired for the first boot on an empty databasePassword for the first admin account. Changing it later has no effect on existing accounts
LICENSE_KEYRequired for a licensed installLicense verification
METRICS_SCRAPE_TOKENOptional, at least 32 charactersBearer token for GET /metrics/prometheus
LOAD_PROBE_TOKENAt least 32 characters; required in production when LOAD_PROBE_ENABLED=trueProtects the load probe endpoint
SENTRY_DSNOptionalSentry error reporting
GRAFANA_ADMIN_PASSWORDOptional, read by docker-compose.observability.ymlGrafana admin login. It falls back to admin if unset, so set it before starting that stack

JWT_ACCESS_EXPIRES_IN and JWT_REFRESH_EXPIRES_IN appear in .env.prod.example but aren’t secrets, and the token code doesn’t read them. By default access tokens last 15 minutes and refresh tokens last 1 day; each refresh issues a new 1-day token.

Generating Values

openssl rand -hex 64   # JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, JWT_2FA_TEMP_SECRET
openssl rand -hex 32   # POSTGRES_PASSWORD, REDIS_PASSWORD, CSRF_SECRET, ENCRYPTION_KEY, METRICS_SCRAPE_TOKEN

Applying a Changed Secret

The app reads secrets when it starts, so every change needs a restart. On Compose:

docker compose --env-file .env.prod -f docker-compose.prod.yml up -d app

On Helm, a chart-managed Secret changes through helm upgrade. If you use secrets.existingSecret, update that Secret and restart the app Deployment yourself: the chart only tracks the Secret’s name, so a change to its contents doesn’t roll the pods.

Plan a maintenance window. Live table state is held in the app’s memory, so a restart interrupts play. Interrupted hands are void and players who are no longer seated get their last committed stack back, as described in Cash Table Funds. See Upgrades and Rollback for planned restarts.

Effect on Sessions

Secret changedWhat users see
JWT_ACCESS_SECRETEvery access token stops working immediately. The web client gets a 401, uses its refresh token to get a new access token and carries on, so most users stay logged in
JWT_REFRESH_SECRETNothing
JWT_2FA_TEMP_SECRETAnyone midway through a 2FA login has to log in again
CSRF_SECRETExisting CSRF tokens fail. The web client fetches a new one and retries when it gets INVALID_CSRF_TOKEN. Other clients must do the same

There’s no dual-key window: the app accepts only the current value of each of these secrets.

Why Refresh Tokens Survive

Refresh tokens are random values stored as SHA-256 hashes in the database. They aren’t JWTs, which is why rotating the JWT secrets leaves them valid.

JWT_REFRESH_SECRET

Keep it set, because startup validation requires it. Rotating it is harmless, but does nothing for you. In particular, it won’t end sessions.

Ending Sessions After a Compromise

Rotating JWT_ACCESS_SECRET stops forged access tokens straight away. It doesn’t end existing sessions, and the Admin Panel has no action that logs out every user at once. What’s available:

  • A user’s refresh tokens are revoked when their password changes, and users can revoke their own with POST /api/v1/auth/revoke-all.
  • Force Disconnect and Block act on one account at a time. See Account Security Controls.

Refresh tokens expire after a day unless they’re used, but each use issues a new one.

What ENCRYPTION_KEY Protects

DataStored inIf it can’t be decryptedHow to re-encrypt with the new key
Player 2FA secretsUser.totpSecretThe player’s 2FA code can’t be verifiedReset 2FA, then the player enrols again
Admin 2FA secretsAdminUser.twoFactorSecretThe admin’s 2FA code can’t be verifiedDisable with a current code, then set up again
Payment provider credentialsPaymentSystem.configThe provider can’t be usedSave the provider’s credentials again
Wallet aggregator provider credentialsProvider configThe provider can’t be usedUpdate the provider
SMTP passwordSMTP settingsEmail silently stops: the app logs Failed to parse stored SMTP config, using defaults, and the defaults have SMTP disabledEnter the password again and save
Plugin settings of type passwordPluginSetting.valueThe plugin can’t read the settingSave the setting again
Developer API key secretsDeveloperApiKey.secretEncryptedSigned partner requests can’t be verifiedRotate Secret, then give the partner the new value
Partner webhook signing secretsDeveloperPartner.webhookSecretEncryptedWebhooks can’t be signedGenerate a new secret, then give the partner the new value
Uploaded KYC documentsFiles in the KYC storage directoryThe file can’t be readNo re-encryption path exists

Passwords (bcrypt) and refresh tokens (SHA-256) are hashed rather than encrypted, so the key doesn’t affect them.

What Happens if the Key Changes or Is Lost

Startup only checks the key’s format, not whether it matches your data, so the app starts normally with a wrong key. The failures in the table above then appear as each record is used. Authentication failures are never bypassed.

If you still have the old key, add it to ENCRYPTION_PREVIOUS_KEYS and restart. Everything becomes readable again.

If the old key is gone, the data can’t be recovered. Restoring a database backup doesn’t help, because the backup holds the same encrypted values and doesn’t contain the key. You’d need to re-enter every provider, SMTP and plugin credential, re-enrol 2FA for every user who had it, and reissue Developer API secrets.

Rotating ENCRYPTION_KEY

  1. Take a database backup and copy the current key somewhere safe outside the server.
  2. Generate a new key with openssl rand -hex 32.
  3. In .env.prod, set the new key and move the old one to the previous list:
    ENCRYPTION_KEY=<new key> ENCRYPTION_PREVIOUS_KEYS=<old key>
  4. Restart the app as described in Applying a Changed Secret.
  5. Check that old records still decrypt: click Test Connection for SMTP, click Test on each payment provider, and log in with a 2FA account.
  6. Re-save what you can, using the table above. New writes use the new key automatically.
  7. Leave the old key in ENCRYPTION_PREVIOUS_KEYS. Records you haven’t re-saved still need it, player 2FA secrets in particular. The app has no report of which key each record uses, so only remove a key if you’re certain nothing depends on it.

Keep these limits in mind:

  • ENCRYPTION_PREVIOUS_KEYS accepts at most three keys. A malformed list or a fourth key makes every decryption fail, including records under the active key. validate-env.sh doesn’t check this variable, so verify it yourself.
  • The Helm chart’s secrets.* values have no entry for ENCRYPTION_PREVIOUS_KEYS. To use it on Kubernetes, add the key to a Secret supplied through secrets.existingSecret.

If the Key Was Compromised

Rotation protects new writes only. Anyone holding the old key and a copy of the database can still read the old records, so replace the secrets themselves:

  • Generate new credentials at each payment provider and SMTP service, save them in the Admin Panel, and revoke the old ones at the provider.
  • Rotate Developer API secrets and webhook signing secrets with a short or zero grace period. See Developer API.
  • Have users with 2FA enrol again, starting with admins.

Rotating Other Secrets

Database Password

The PostgreSQL container only applies POSTGRES_PASSWORD when it initialises an empty data volume, so changing the variable alone locks the app out. Change the password in the database first:

docker compose --env-file .env.prod -f docker-compose.prod.yml exec postgres psql -U poker poker_engine -c "ALTER USER poker WITH PASSWORD '<new password>';"

Replace poker if you changed POSTGRES_USER or POSTGRES_DB. Then set POSTGRES_PASSWORD to the same value in .env.prod and recreate everything that uses it:

docker compose --env-file .env.prod -f docker-compose.prod.yml up -d app backup

Use a hex password from openssl rand -hex 32, because it’s placed in a connection URL. If you run the observability stack, recreate its PostgreSQL exporter too.

Redis Password

Set REDIS_PASSWORD in .env.prod, then recreate Redis and the app together:

docker compose --env-file .env.prod -f docker-compose.prod.yml up -d redis app

If you run the observability stack, recreate its Redis exporter too.

Metrics Token

Change METRICS_SCRAPE_TOKEN and restart the app. If you use the bundled Prometheus, update deploy/observability/prometheus/secrets/metrics-token to match, as that’s where it reads the token from.

License Key

To move an activation, see Operator FAQ. Update LICENSE_KEY and LICENSE_DOMAIN, restart the app, then click Verify Now under License & Updates.

Payment and SMTP Credentials

Create the new credential at the provider, save it in the Admin Panel, test it, and only then revoke the old one. See Cashier and Payments and System Emails.

The Data Migration Scripts

The source tree contains three scripts. None of them rotates a key, and none is included in a standard install.

ScriptWhat it doesWith already-migrated records
scripts/encrypt-payment-configs.tsEncrypts plaintext PaymentSystem.config valuesSkips them. Records it can’t decrypt are logged with a warning and skipped
scripts/encrypt-totp-secrets.tsEncrypts plaintext 2FA secrets for users and adminsSkips anything that already looks encrypted
scripts/hash-refresh-tokens.tsReplaces plaintext refresh tokens with SHA-256 hashesSkips values that are already 64 hex characters

These are one-time upgrades for records written before encryption and hashing were introduced. They only act on plaintext, so running them after a key change does nothing to records encrypted under the old key.

Are They in My Install?

  • Docker and Helm installs: no. The release tarballs include only the deployment, backup and manifest-verification scripts. The Docker image excludes scripts/ and is built with production dependencies only, so tsx, which runs these scripts, isn’t there either.
  • Enterprise source: yes. With a checkout, its dependencies installed, and DATABASE_URL set (plus ENCRYPTION_KEY for the first two), you can run:
    npx tsx scripts/encrypt-totp-secrets.ts

Legacy Plaintext Values

Production installs reject stored secrets that aren’t valid ciphertext. Setting ALLOW_LEGACY_PLAINTEXT_SECRETS=true makes the app accept such values as they are, which is meant only for a temporary migration. It doesn’t accept ciphertext that fails authentication. Re-save the affected records, then set it back to false.

Try the live demo Pricing

Have questions about Poker Engine? We reply fast.