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_KEYoutside the server. Without it, encrypted credentials and authenticator secrets can’t be recovered. Database backups don’t contain it. - Rotating
ENCRYPTION_KEYdoesn’t re-encrypt anything. Old records stay readable only while the old key is listed inENCRYPTION_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 type | Location |
|---|---|
| Docker Compose | .env.prod in the install directory, passed to the app with env_file |
| Helm | A Kubernetes Secret, either created by the chart from secrets.* values or supplied by you through secrets.existingSecret |
| Both | Payment 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.
| Variable | Requirement | Used for |
|---|---|---|
POSTGRES_PASSWORD | Required (Compose) | PostgreSQL login. docker-compose.prod.yml builds DATABASE_URL from it |
DATABASE_URL | Required (Helm, or if you run outside Compose) | Database connection, including the password |
REDIS_PASSWORD | Required (Compose) | Redis login. docker-compose.prod.yml builds REDIS_URL from it |
JWT_ACCESS_SECRET | Required, at least 64 characters | Signs access tokens |
JWT_REFRESH_SECRET | Required, at least 64 characters | Validated at startup, but nothing currently signs with it. See below |
JWT_2FA_TEMP_SECRET | Required, at least 64 characters | Signs the 5-minute token issued between password and 2FA steps |
CSRF_SECRET | Required, at least 32 characters | CSRF token validation |
ENCRYPTION_KEY | Required in production, exactly 64 hex characters | AES-256-GCM encryption of stored secrets |
ENCRYPTION_PREVIOUS_KEYS | Optional, up to 3 comma-separated 64-character hex keys | Older keys still accepted for decryption |
ADMIN_INITIAL_PASSWORD | Required for the first boot on an empty database | Password for the first admin account. Changing it later has no effect on existing accounts |
LICENSE_KEY | Required for a licensed install | License verification |
METRICS_SCRAPE_TOKEN | Optional, at least 32 characters | Bearer token for GET /metrics/prometheus |
LOAD_PROBE_TOKEN | At least 32 characters; required in production when LOAD_PROBE_ENABLED=true | Protects the load probe endpoint |
SENTRY_DSN | Optional | Sentry error reporting |
GRAFANA_ADMIN_PASSWORD | Optional, read by docker-compose.observability.yml | Grafana 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 changed | What users see |
|---|---|
JWT_ACCESS_SECRET | Every 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_SECRET | Nothing |
JWT_2FA_TEMP_SECRET | Anyone midway through a 2FA login has to log in again |
CSRF_SECRET | Existing 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
| Data | Stored in | If it can’t be decrypted | How to re-encrypt with the new key |
|---|---|---|---|
| Player 2FA secrets | User.totpSecret | The player’s 2FA code can’t be verified | Reset 2FA, then the player enrols again |
| Admin 2FA secrets | AdminUser.twoFactorSecret | The admin’s 2FA code can’t be verified | Disable with a current code, then set up again |
| Payment provider credentials | PaymentSystem.config | The provider can’t be used | Save the provider’s credentials again |
| Wallet aggregator provider credentials | Provider config | The provider can’t be used | Update the provider |
| SMTP password | SMTP settings | Email silently stops: the app logs Failed to parse stored SMTP config, using defaults, and the defaults have SMTP disabled | Enter the password again and save |
Plugin settings of type password | PluginSetting.value | The plugin can’t read the setting | Save the setting again |
| Developer API key secrets | DeveloperApiKey.secretEncrypted | Signed partner requests can’t be verified | Rotate Secret, then give the partner the new value |
| Partner webhook signing secrets | DeveloperPartner.webhookSecretEncrypted | Webhooks can’t be signed | Generate a new secret, then give the partner the new value |
| Uploaded KYC documents | Files in the KYC storage directory | The file can’t be read | No 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
- Take a database backup and copy the current key somewhere safe outside the server.
- Generate a new key with
openssl rand -hex 32. - 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> - Restart the app as described in Applying a Changed Secret.
- Check that old records still decrypt: click Test Connection for SMTP, click Test on each payment provider, and log in with a 2FA account.
- Re-save what you can, using the table above. New writes use the new key automatically.
- 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_KEYSaccepts at most three keys. A malformed list or a fourth key makes every decryption fail, including records under the active key.validate-env.shdoesn’t check this variable, so verify it yourself.- The Helm chart’s
secrets.*values have no entry forENCRYPTION_PREVIOUS_KEYS. To use it on Kubernetes, add the key to a Secret supplied throughsecrets.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.
| Script | What it does | With already-migrated records |
|---|---|---|
scripts/encrypt-payment-configs.ts | Encrypts plaintext PaymentSystem.config values | Skips them. Records it can’t decrypt are logged with a warning and skipped |
scripts/encrypt-totp-secrets.ts | Encrypts plaintext 2FA secrets for users and admins | Skips anything that already looks encrypted |
scripts/hash-refresh-tokens.ts | Replaces plaintext refresh tokens with SHA-256 hashes | Skips 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, sotsx, which runs these scripts, isn’t there either. - Enterprise source: yes. With a checkout, its dependencies installed, and
DATABASE_URLset (plusENCRYPTION_KEYfor 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.