Rotate secrets
This page helps you replace each of Goiabada’s secrets without signing anybody out, or with the shortest interruption that secret allows.
Every rotation works the same way: a server reads its secrets only when it starts, so you change a secret where your setup keeps it, then restart the server that reads it. Each step has a tab per platform for its commands. Pick yours once, and every tab on the page follows.
Generate a new key with openssl rand -hex 64 for a session authentication key and openssl rand -hex 32 for the rest. Secrets says what each one protects.
The session keys
Section titled “The session keys”Each server seals its browser sessions with its current session key pair, and opens them with that pair and then, when it’s set, a previous pair. So you make the new pair current and keep the old one as the previous pair for as long as a session sealed under it can live. Done this way, nobody is signed out.
-
Make new pairs current, and the old pairs previous, then restart both servers.
In
docker-compose.override.yml, give each server’s current pair to its previous pair, and new keys to the current pair:goiabada-authserver:environment:# ...the other entries stay as they are- "GOIABADA_AUTHSERVER_SESSION_AUTHENTICATION_KEY=<new, openssl rand -hex 64>"- "GOIABADA_AUTHSERVER_SESSION_ENCRYPTION_KEY=<new, openssl rand -hex 32>"- "GOIABADA_AUTHSERVER_SESSION_AUTHENTICATION_KEY_PREVIOUS=<its value before>"- "GOIABADA_AUTHSERVER_SESSION_ENCRYPTION_KEY_PREVIOUS=<its value before>"goiabada-adminconsole:environment:# ...the other entries stay as they are- "GOIABADA_ADMINCONSOLE_SESSION_AUTHENTICATION_KEY=<new, openssl rand -hex 64>"- "GOIABADA_ADMINCONSOLE_SESSION_ENCRYPTION_KEY=<new, openssl rand -hex 32>"- "GOIABADA_ADMINCONSOLE_SESSION_AUTHENTICATION_KEY_PREVIOUS=<its value before>"- "GOIABADA_ADMINCONSOLE_SESSION_ENCRYPTION_KEY_PREVIOUS=<its value before>"Then restart:
Terminal window docker compose up -dCompose stops a changed service’s container before it starts the new one, so two containers of one service never run together, and one restart does it.
In the env file, give each server’s current pair to its previous pair, and new keys to the current pair:
Terminal window GOIABADA_AUTHSERVER_SESSION_AUTHENTICATION_KEY="<new, openssl rand -hex 64>"GOIABADA_AUTHSERVER_SESSION_ENCRYPTION_KEY="<new, openssl rand -hex 32>"GOIABADA_AUTHSERVER_SESSION_AUTHENTICATION_KEY_PREVIOUS="<its value before>"GOIABADA_AUTHSERVER_SESSION_ENCRYPTION_KEY_PREVIOUS="<its value before>"GOIABADA_ADMINCONSOLE_SESSION_AUTHENTICATION_KEY="<new, openssl rand -hex 64>"GOIABADA_ADMINCONSOLE_SESSION_ENCRYPTION_KEY="<new, openssl rand -hex 32>"GOIABADA_ADMINCONSOLE_SESSION_AUTHENTICATION_KEY_PREVIOUS="<its value before>"GOIABADA_ADMINCONSOLE_SESSION_ENCRYPTION_KEY_PREVIOUS="<its value before>"Then restart both servers:
Terminal window sudo systemctl restart goiabada-authserver goiabada-adminconsoleEach server runs as one process, which a restart stops before it starts the new one, so one restart does it.
A rollout runs old and new pods side by side, and each must open what the other seals. So the new pairs go in first as the previous pairs, and the two are then swapped, in two rollouts.
Add the new pairs as the previous pairs. Every pod still seals under the current pair, and can now open the new one, which nothing uses yet:
Terminal window kubectl patch secret goiabada-secrets -n goiabada --type=merge --patch-file=/dev/stdin <<EOF{"stringData": {"auth-session-auth-key-previous": "$(openssl rand -hex 64)","auth-session-enc-key-previous": "$(openssl rand -hex 32)","admin-session-auth-key-previous": "$(openssl rand -hex 64)","admin-session-enc-key-previous": "$(openssl rand -hex 32)"}}EOFkubectl rollout restart deployment/goiabada-authserver deployment/goiabada-adminconsole -n goiabadakubectl rollout status deployment/goiabada-authserver deployment/goiabada-adminconsole -n goiabadaThe patch reads the new keys from a here-document, so they never reach the process list, and adds both halves of each pair at once. The restarted pods read them through the manifest’s optional references: the auth server as
GOIABADA_AUTHSERVER_SESSION_AUTHENTICATION_KEY_PREVIOUSandGOIABADA_AUTHSERVER_SESSION_ENCRYPTION_KEY_PREVIOUS, the admin console asGOIABADA_ADMINCONSOLE_SESSION_AUTHENTICATION_KEY_PREVIOUSandGOIABADA_ADMINCONSOLE_SESSION_ENCRYPTION_KEY_PREVIOUS.When this ends,
goiabada-secretsholds the current pairs under their usual keys and the new pairs under the four-previouskeys.Swap the pairs, so that the new one is current and the old one previous, and restart:
Terminal window key() { kubectl get secret goiabada-secrets -n goiabada -o "jsonpath={.data.$1}"; }kubectl patch secret goiabada-secrets -n goiabada --type=merge --patch-file=/dev/stdin <<EOF{"data": {"auth-session-auth-key": "$(key auth-session-auth-key-previous)","auth-session-enc-key": "$(key auth-session-enc-key-previous)","admin-session-auth-key": "$(key admin-session-auth-key-previous)","admin-session-enc-key": "$(key admin-session-enc-key-previous)","auth-session-auth-key-previous": "$(key auth-session-auth-key)","auth-session-enc-key-previous": "$(key auth-session-enc-key)","admin-session-auth-key-previous": "$(key admin-session-auth-key)","admin-session-enc-key-previous": "$(key admin-session-enc-key)"}}EOFkubectl rollout restart deployment/goiabada-authserver deployment/goiabada-adminconsole -n goiabadakubectl rollout status deployment/goiabada-authserver deployment/goiabada-adminconsole -n goiabadaEvery value is read before the patch is sent, so the swap is whole. During this rollout a new pod seals under the new pair and opens the old, and an old pod seals under the old pair and opens the new, so either opens every session.
When this ends, the usual keys hold the new pairs and the
-previouskeys the old ones; the Secret must hold that before the restart. -
Wait for the maximum session lifetime since the new pairs became current. It’s on the admin console’s Settings → Sessions page, 24 hours unless you changed it, and it bounds both servers’ sessions. A session is sealed under the new pair the next time it’s saved, and none outlives that lifetime, so by then every signed-in session is under the new pair.
-
Remove the previous pairs, and restart both servers, so that nothing opens sessions under the old pair any more.
Delete the four
_PREVIOUSlines fromdocker-compose.override.yml, then:Terminal window docker compose up -dDelete the four
_PREVIOUSlines from the env file, then:Terminal window sudo systemctl restart goiabada-authserver goiabada-adminconsoleTerminal window kubectl patch secret goiabada-secrets -n goiabada --type=json -p='[{"op": "remove", "path": "/data/auth-session-auth-key-previous"},{"op": "remove", "path": "/data/auth-session-enc-key-previous"},{"op": "remove", "path": "/data/admin-session-auth-key-previous"},{"op": "remove", "path": "/data/admin-session-enc-key-previous"}]'kubectl rollout restart deployment/goiabada-authserver deployment/goiabada-adminconsole -n goiabadakubectl rollout status deployment/goiabada-authserver deployment/goiabada-adminconsole -n goiabadaWhen this ends,
goiabada-secretsholds its seven keys again, the session keys being the new pairs.
A sign-in still in progress, one not yet signed in, can still be under the old pair, so removing it may restart that sign-in, at the cost of the form being filled in. Replacing the keys in one step instead, with no previous pair, signs everybody out once.
A previous pair is read whole or not at all: a server refuses to start with one half of it set, so add and remove its two keys together.
The AES key
Section titled “The AES key”The auth server encrypts and decrypts with GOIABADA_AES_ENCRYPTION_KEY alone. It reads GOIABADA_AES_ENCRYPTION_KEY_PREVIOUS only when it starts: if the database is still under that key, it re-encrypts all of it to the current one, in one transaction, before it listens.
From that commit on, an auth server still running with the old key decrypts nothing, and whatever it encrypts, a new client secret or an authenticator, is written under a key the database is no longer under and stays unreadable. So the key rotates with one auth server starting and no other running.
-
Back up the database, and the current key, as Back up the AES key says.
-
Make sure no other auth server runs while the new key is first used.
Nothing to do: Compose stops the old container before it starts the new one.
Run one auth server process. Stop any other one before step 5.
A rollout always overlaps old and new pods, at one replica too, since the generated
maxSurge: 1starts the new pod before it stops the old one. So stop every auth server pod:Terminal window kubectl scale deployment goiabada-authserver -n goiabada --replicas=0kubectl wait pod -n goiabada -l app=goiabada-authserver --for=delete --timeout=2mThe auth server is down from here until step 5, and the admin console’s requests to it fail until it’s back.
-
Make a new key current, and the old one previous.
Under
goiabada-authserverindocker-compose.override.yml, setGOIABADA_AES_ENCRYPTION_KEYto a new key,openssl rand -hex 32, and add the key it held before:goiabada-authserver:environment:# ...the other entries stay as they are- "GOIABADA_AES_ENCRYPTION_KEY=<new, openssl rand -hex 32>"- "GOIABADA_AES_ENCRYPTION_KEY_PREVIOUS=<its value before>"In the env file, set
GOIABADA_AES_ENCRYPTION_KEYto a new key,openssl rand -hex 32, and add the key it held before:Terminal window GOIABADA_AES_ENCRYPTION_KEY="<new, openssl rand -hex 32>"GOIABADA_AES_ENCRYPTION_KEY_PREVIOUS="<its value before>"Terminal window kubectl patch secret goiabada-encryption-key -n goiabada --type=merge --patch-file=/dev/stdin <<EOF{"data": {"aes-encryption-key-previous": "$(kubectl get secret goiabada-encryption-key -n goiabada -o jsonpath='{.data.aes-encryption-key}')"},"stringData": {"aes-encryption-key": "$(openssl rand -hex 32)"}}EOFWhen this ends,
aes-encryption-keyholds a new key, andaes-encryption-key-previousthe key the database is under, the one you backed up in step 1. -
Back up the new key now, as Back up the AES key says: the next start puts the database under it.
-
Start the auth server, and check that the re-encryption committed.
Terminal window docker compose up -d --wait goiabada-authserverdocker compose logs goiabada-authserver | grep 'rotated data-at-rest encryption'Terminal window sudo systemctl restart goiabada-authserveruntil curl -sf http://127.0.0.1:9090/health; do sleep 2; done; echosudo journalctl -u goiabada-authserver | grep 'rotated data-at-rest encryption'Start one pod with both keys:
Terminal window kubectl scale deployment goiabada-authserver -n goiabada --replicas=1kubectl rollout status deployment/goiabada-authserver -n goiabadakubectl logs -n goiabada deployment/goiabada-authserver | grep 'rotated data-at-rest encryption'The pod reads
aes-encryption-key-previousasGOIABADA_AES_ENCRYPTION_KEY_PREVIOUSthrough the manifest’s optional reference. Once the record is there, scale back to your replica count. Every pod now starts with the new key, so this and every later rollout overlaps safely:Terminal window kubectl scale deployment goiabada-authserver -n goiabada --replicas=<n>The record
rotated data-at-rest encryption to the new GOIABADA_AES_ENCRYPTION_KEYsays the re-encryption committed. From then on the database is under the new key alone, andGOIABADA_AES_ENCRYPTION_KEYmust go on holding it: an auth server started with the old key there refuses to start, withthe data encryption key does not decrypt the stored data, so the auth server cannot start.If the auth server stops instead with
data-at-rest decrypts under neither GOIABADA_AES_ENCRYPTION_KEY nor GOIABADA_AES_ENCRYPTION_KEY_PREVIOUS, the previous key isn’t the one the database is under, and nothing was re-encrypted. The start migrates the database before it checks the keys, so a start that is also an upgrade has migrated its schema all the same. -
Remove the previous key, and restart the auth server, once you’ve checked that sign-ins and your clients work. Until then it’s harmless: at each start the auth server finds the database under the current key and re-encrypts nothing.
Delete the
GOIABADA_AES_ENCRYPTION_KEY_PREVIOUSline fromdocker-compose.override.yml, then:Terminal window docker compose up -d goiabada-authserverDelete the
GOIABADA_AES_ENCRYPTION_KEY_PREVIOUSline from the env file, then:Terminal window sudo systemctl restart goiabada-authserverTerminal window kubectl patch secret goiabada-encryption-key -n goiabada --type=json \-p='[{"op": "remove", "path": "/data/aes-encryption-key-previous"}]'kubectl rollout restart deployment/goiabada-authserver -n goiabadakubectl rollout status deployment/goiabada-authserver -n goiabadaThe restart takes the old key out of the pods’ environment. When this ends,
goiabada-encryption-keyholds the new key alone.
Keep the old key as long as you keep a database backup taken before the rotation.
The database password
Section titled “The database password”GOIABADA_DB_PASSWORD is the password of the database user the auth server signs in as, GOIABADA_DB_USERNAME. The database checks a password only when a connection opens, so a running auth server keeps the connections it holds through a change, and opens every new one with the password it started with. What a rotation costs depends on whether the database accepts two passwords for one user at once: MySQL 8.0.14 and later do, PostgreSQL and SQL Server don’t.
On MySQL
Section titled “On MySQL”-
Give the user its new password, keeping the current one as a second password that’s still accepted.
openssl rand -hex 32makes one.The image creates
roottwice: for connections from other containers (%), which is the auth server’s, and from inside its own (localhost). Indocker compose exec mysql-server mysql -uroot -p:ALTER USER 'root'@'%' IDENTIFIED BY '<new password>' RETAIN CURRENT PASSWORD;ALTER USER 'root'@'localhost' IDENTIFIED BY '<new password>' RETAIN CURRENT PASSWORD;Name the user and host your deployment signs in as:
ALTER USER 'goiabada'@'%' IDENTIFIED BY '<new password>' RETAIN CURRENT PASSWORD;Run it as an account with the
CREATE USERprivilege; the user itself can run it on its own account only withAPPLICATION_PASSWORD_ADMIN.Name the user and host your deployment signs in as, the
GOIABADA_DB_USERNAMEofgoiabada-authserver-config:ALTER USER 'goiabada'@'%' IDENTIFIED BY '<new password>' RETAIN CURRENT PASSWORD;Run it as an account with the
CREATE USERprivilege; the user itself can run it on its own account only withAPPLICATION_PASSWORD_ADMIN. -
Store the new password where the auth server reads it, and restart the auth server. During the restart the old password and the new one are both accepted, so nothing fails.
In
docker-compose.override.yml, setGOIABADA_DB_PASSWORDandMYSQL_ROOT_PASSWORDto the new password, then restart the auth server alone:Terminal window docker compose up -d --no-deps goiabada-authserverWithout
--no-deps, Compose would also recreate the database’s container, whose variable changed, restarting the database under the auth server. That container takes the new value the next time it’s recreated, which changes nothing in the database.Set
GOIABADA_DB_PASSWORDto the new password in the env file, then:Terminal window sudo systemctl restart goiabada-authserverTerminal window read -rsp 'New database password: ' DB_PASSWORD; echokubectl patch secret goiabada-secrets -n goiabada --type=merge --patch-file=/dev/stdin <<EOF{"data": {"db-password": "$(printf %s "$DB_PASSWORD" | base64 | tr -d '\n')"}}EOFunset DB_PASSWORDkubectl rollout restart deployment/goiabada-authserver -n goiabadakubectl rollout status deployment/goiabada-authserver -n goiabadaThe value goes in base64, under
data, so no character of the password needs escaping. During the rollout old pods sign in with the old password and new pods with the new. When this ends,db-passwordholds the new password. -
Discard the old password once the auth server is back.
ALTER USER 'root'@'%' DISCARD OLD PASSWORD;ALTER USER 'root'@'localhost' DISCARD OLD PASSWORD;ALTER USER 'goiabada'@'%' DISCARD OLD PASSWORD;ALTER USER 'goiabada'@'%' DISCARD OLD PASSWORD;
On PostgreSQL and SQL Server
Section titled “On PostgreSQL and SQL Server”These hold one password per user, which a change replaces at once, so the auth server has to restart right after it.
-
Store the new password where the auth server reads it, without restarting anything. No running server reads it, and it’s now stored before the database depends on it. On PostgreSQL,
openssl rand -hex 32makes one; SQL Server’s password policy asks for three of upper case, lower case, digits and symbols, which a hex string lacks.In
docker-compose.override.yml, setGOIABADA_DB_PASSWORDandPOSTGRES_PASSWORDorMSSQL_SA_PASSWORDto the new password.Set
GOIABADA_DB_PASSWORDto the new password in the env file.Run the
kubectl patchof the MySQL step 2, without the restart. A pod that starts before step 2 can’t sign in, stops, and is restarted until step 2 is done. When this ends,db-passwordholds the new password. -
Change the user’s password to the same value. On PostgreSQL,
\passwordasks for the new password twice without echoing it, and sends it hashed, so it reaches neither the server log norpsql’s history.On PostgreSQL, run
docker compose exec postgres-server psql -U postgres, then:\password postgresOn SQL Server, in
docker compose exec mssql-server /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -C, which asks for the current password:ALTER LOGIN sa WITH PASSWORD = '<new password>';GOOn PostgreSQL, in
psql, as the user itself or a role allowed to change its password:\password goiabadaOn SQL Server, as a login with the
ALTER ANY LOGINpermission, or asgoiabadaitself withOLD_PASSWORD = '<current password>'added:ALTER LOGIN goiabada WITH PASSWORD = '<new password>';On PostgreSQL, in
psql, as the user itself or a role allowed to change its password:\password goiabadaOn SQL Server, as a login with the
ALTER ANY LOGINpermission, or asgoiabadaitself withOLD_PASSWORD = '<current password>'added:ALTER LOGIN goiabada WITH PASSWORD = '<new password>'; -
Restart the auth server at once.
Terminal window docker compose up -d --no-deps goiabada-authserverWithout
--no-deps, Compose would also recreate the database’s container, whose variable changed, restarting the database under the auth server. On SQL Server that container’s healthcheck signs in with the value it holds, so it reports unhealthy until it’s recreated: recreate it withdocker compose up -dwhen a short database restart suits.Terminal window sudo systemctl restart goiabada-authserverTerminal window kubectl rollout restart deployment/goiabada-authserver -n goiabadakubectl rollout status deployment/goiabada-authserver -n goiabada
Between steps 2 and 3, a running auth server keeps the connections it holds, but each one it opens is refused, and the request that needed it fails with a 500. It opens connections as its traffic grows, and replaces each after 5 idle minutes (GOIABADA_DB_CONN_MAX_IDLE_TIME) or 30 minutes of life (GOIABADA_DB_CONN_MAX_LIFETIME), so the shorter the gap, the fewer requests fail.
If the auth server then stops with Access denied, password authentication failed or Login failed in its log, the password you stored and the database’s disagree: set the database user’s password to the one you stored. On Kubernetes, read it back with kubectl get secret goiabada-secrets -n goiabada -o jsonpath='{.data.db-password}' | base64 -d.
The admin console’s client secret
Section titled “The admin console’s client secret”GOIABADA_ADMINCONSOLE_OAUTH_CLIENT_SECRET is the secret of the admin console’s client, admin-console-client, which the auth server keeps, encrypted, in its database. The auth server reads its own copy only at its first start, to create that client, so it needs no restart. The admin console presents the secret on every sign-in, on every refresh of an administrator’s access token, and for the token it asks for to reach its own sessions. So a rotation changes the client’s secret in the database and the admin console’s copy, then restarts the admin console.
-
In the admin console, open Clients, then
admin-console-client, then its Authentication tab. Click Generate new secret, then Copy. Don’t save yet. -
Store the copied secret where the admin console reads it. Nothing reads it before a restart, and it’s now stored before the database depends on it.
Paste it as
GOIABADA_ADMINCONSOLE_OAUTH_CLIENT_SECRETunder both services indocker-compose.override.yml. The auth server’s copy is read only to set up an empty database, so it can take the new one at its next restart.Paste it as
GOIABADA_ADMINCONSOLE_OAUTH_CLIENT_SECRETin the env file.Terminal window read -rsp 'New client secret: ' CLIENT_SECRET; echokubectl patch secret goiabada-secrets -n goiabada --type=merge --patch-file=/dev/stdin <<EOF{"stringData": {"oauth-client-secret": "$CLIENT_SECRET"}}EOFunset CLIENT_SECRETA generated secret holds letters, digits,
-,_and.alone, so it needs no escaping. An admin console pod that starts before step 3 stops at once, because the auth server refuses the new secret until then, and the earlier pods keep serving. When this ends,oauth-client-secretholds the new secret. -
Save the page, and confirm both warnings: that you’re changing the client secret, then that this is a system-level client. The database now holds the new secret.
-
Restart the admin console at once, then check by signing in from a private window.
Terminal window docker compose up -d --no-deps goiabada-adminconsoleWithout
--no-deps, Compose would also recreate the auth server, whose copy changed, stopping sign-in while it restarts.Terminal window sudo systemctl restart goiabada-adminconsoleTerminal window kubectl rollout restart deployment/goiabada-adminconsole -n goiabadakubectl rollout status deployment/goiabada-adminconsole -n goiabadaThe rollout completes only once the auth server accepts the secret the new pods hold: a pod it refuses stops before it listens, and the rollout waits with the earlier pods still serving. Sign in from the private window all the same.
Between steps 3 and 4, the auth server refuses the old secret, which the running admin console presents. A sign-in fails at its last step, an administrator whose access token falls due is signed out, and once the token the admin console holds for its sessions expires, within the access token lifetime, 5 minutes unless you changed it, every page behind the sign-in answers 500. Administrators signed in before step 3 stay signed in through the restart. The auth server and every other client work throughout.
If the admin console can no longer sign you in, because the database holds a secret its copy doesn’t, Locked out of the admin console sets a secret you know through the admin API. Have the client it needs before you rotate: once the admin console is locked out, it’s the only way back in.
The admin password
Section titled “The admin password”GOIABADA_ADMIN_PASSWORD is read only by the first start, which creates the administrator. Change the administrator’s password in the admin console; changing the variable changes nothing.