Security Guide

Guidance for operators on rotating secrets, recovering from a breach, and hardening a Baander installation.

Rotating APP_SECRET

APP_SECRET is used to sign session cookies, CSRF tokens, and other Symfony security components. Rotating it invalidates all existing sessions and CSRF tokens — all users will be logged out.

Step-by-step

  1. Generate a new secret:
php -r 'echo bin2hex(random_bytes(32));'
  1. Support rotation (zero downtime):

Symfony supports comma-separated secrets for live rotation. The first value is used for signing; the rest are used only for verification.

APP_SECRET=new_secret_here,old_secret_here

This lets existing sessions remain valid while new sessions use the new secret.

  1. Remove the old secret after sessions expire:

Wait for the old sessions to naturally expire (or clear the Redis session store), then remove the old value:

APP_SECRET=new_secret_here
  1. Restart the application:
make stop && make start

When to rotate

Rotating OAuth 2.0 Keys

OAuth keys sign JWT access tokens. Rotating them invalidates all existing access and refresh tokens — all API clients must re-authenticate.

Using the command (recommended)

The app:auth:rotate-secrets command automates the full rotation:

make exec cmd="php bin/console app:auth:rotate-secrets"

This backs up existing keys, generates a new key pair, truncates OAuth token tables, invalidates the token cache, and outputs the new encryption key. Follow the printed instructions to add the encryption key and restart.

Manual rotation

For zero-downtime key rotation (where existing tokens remain valid until they naturally expire), manual key replacement is required:

  1. Generate a new key pair:
openssl genrsa -out config/secrets/oauth/private-new.key 2048
openssl rsa -in config/secrets/oauth/private-new.key -pubout > config/secrets/oauth/public-new.key
  1. Replace the keys:
mv config/secrets/oauth/private.key config/secrets/oauth/private-old.key
mv config/secrets/oauth/public.key config/secrets/oauth/public-old.key
mv config/secrets/oauth/private-new.key config/secrets/oauth/private.key
mv config/secrets/oauth/public-new.key config/secrets/oauth/public.key
  1. Restart:
make stop && make start

Rotating Redis Password

Redis stores sessions, rate limiter state, messenger jobs, and cache tags. Rotating the password requires updating all references simultaneously.

Step-by-step

  1. Update .env:
REDIS_PASSWORD=new_password_here
  1. Update docker-compose.yml (the Redis service and any services that pass it as a variable):
redis:
  command: redis-server --maxmemory-policy noeviction --requirepass new_password_here
  environment:
    REDIS_PASSWORD: new_password_here
  1. Restart everything at once:
docker compose down && docker compose up -d

The FLUSHDB approach doesn't work here because you can't authenticate with the new password against the old Redis instance. A full restart is required.

Consequence: All sessions are lost (users logged out), all cached data is cleared, and pending messenger jobs are lost.

Rotating VAPID Keys

Rotating VAPID keys invalidates all existing push subscriptions. Browsers must re-subscribe.

Step-by-step

  1. Generate new keys:
make exec cmd="php bin/console app:generate-vapid-keys"
  1. Update .env with the new public and private keys.

  2. Restart the application:

make stop && make start

Users will need to re-enable push notifications in their browser/client. There is no way to migrate existing subscriptions to new keys.

Rotating Database Credentials

  1. Create a new database user and grant access:
CREATE USER baander_new WITH PASSWORD 'new_password';
GRANT ALL PRIVILEGES ON DATABASE baander TO baander_new;
  1. Update DATABASE_URL in .env:
DATABASE_URL="postgresql://baander_new:new_password@database:5432/baander?serverVersion=18&charset=utf8"
  1. Restart the application:
make stop && make start
  1. Drop the old user once the application is confirmed running:
DROP USER baander;

Breach Recovery

If you suspect or confirm that the installation has been breached, follow these steps in order.

1. Identify what was exposed

Check which secrets an attacker may have accessed:

Access level Exposed secrets
.env file read All secrets — this is the worst case
Database access User passwords (hashed), email addresses, OAuth tokens, push subscriptions
Redis access Active sessions, rate limiter state, pending jobs
Source code read No secrets directly, but reveals architecture

2. Rotate all secrets

There is no shortcut — rotate everything:

# 1. Generate new APP_SECRET
NEW_SECRET=$(php -r 'echo bin2hex(random_bytes(32));')
echo "Rotate APP_SECRET to: $NEW_SECRET"

# 2. Rotate OAuth keys (also generates new encryption key and invalidates tokens)
make exec cmd="php bin/console app:auth:rotate-secrets"

# 3. Generate new VAPID keys
make exec cmd="php bin/console app:generate-vapid-keys"

# 4. Generate new Redis password and update docker-compose.yml

# 5. Generate new database password (see section above)

3. Invalidate all sessions and tokens

# Flush Redis (clears sessions, caches, rate limiter state, pending jobs)
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" FLUSHALL

Warning: This also clears pending messenger jobs (library scans, notification deliveries). Re-run any critical scans after recovery.

4. Force all users to re-authenticate

After rotating APP_SECRET and flushing Redis, all existing sessions and OAuth tokens are invalid. Users will need to log in again.

If OAuth refresh tokens are a concern, consider truncating the relevant database tables:

TRUNCATE oauth_access_tokens;
TRUNCATE oauth_refresh_tokens;
TRUNCATE oauth_auth_codes;

5. Audit user accounts

Check for accounts that may have been created or modified during the breach:

-- Users created in the last 24 hours
SELECT id, email, name, created_at
FROM users
WHERE created_at > NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC;

-- Users with admin roles
SELECT id, email, name
FROM users
WHERE roles::jsonb ? 'ROLE_ADMIN';

Revoke admin privileges from any suspicious accounts and consider locking down user registration (auth.rate_limit.register.max_attempts).

6. Review access logs

Check for suspicious API activity:

# Check Docker logs for unusual patterns
docker compose logs app --since 24h | grep -i "auth\|login\|password"

7. Harden before going live

After recovery, review the hardening checklist before bringing the instance back online.

Securing the Installation

Hardening checklist

Production .env template

APP_ENV=prod
APP_SECRET=<generate with: php -r 'echo bin2hex(random_bytes(32));'>
APP_URL=https://baander.example.com
APP_DOMAIN=baander.example.com
APP_NAME=Bånder
DEFAULT_URI=https://baander.example.com

DATABASE_URL="postgresql://baander:<strong_password>@database:5432/baander?serverVersion=18&charset=utf8"

REDIS_PASSWORD=<strong_password>
REDIS_URL=redis://default:<strong_password>@redis:6379
MESSENGER_TRANSPORT_DSN=redis://default:<strong_password>@redis:6379/messages
MESSENGER_CONSUMER_NAME=${HOSTNAME:-worker}

MAILER_DSN=smtp://user:pass@smtp.example.com:587

VAPID_PUBLIC_KEY=<from app:generate-vapid-keys>
VAPID_PRIVATE_KEY=<from app:generate-vapid-keys>

Network security

Baander runs behind Nginx in Docker Compose. By default:

Review your docker-compose.yml and ensure only the Nginx ports are published to the host.

File permissions

Password security

Passwords are hashed with Argon2id (memory cost: 65536, time cost: 4). This is a strong default. Only increase these values if you have specific compliance requirements and sufficient server memory.

If an attacker obtains the database, they cannot reverse hashed passwords — but they can attempt to crack weak ones. Encourage users to use strong passwords (the minimum is 8 characters, enforced by the create-user command).