Rotate secrets — HMAC keys and AUTH_SECRET
This runbook rotates the two secrets that gate inter-service trust in a Polaris Express deployment:
- The HMAC signing key that the Fresh web app uses to authenticate outbound requests to the Cloudflare Email Worker.
- The BetterAuth signing secret (
AUTH_SECRET) that signs customer and admin session cookies.
Both rotations are designed to be zero-downtime when followed in order. Read the whole runbook before you start.
When to run this
Section titled “When to run this”- Scheduled rotation. Quarterly is a reasonable cadence for both secrets. Pick a calendar and stick to it.
- After a suspected leak. Anything that touched
.env, the Cloudflare dashboard, or a developer laptop where these values were visible. - After offboarding. Anyone who had read access to production secrets leaves the team.
- After a Worker or web-app compromise. Treat as breach until proven otherwise.
Rotate the Email Worker HMAC key
Section titled “Rotate the Email Worker HMAC key”The Worker accepts a signature from either POLARIS_SECRET_A or
POLARIS_SECRET_B. That second slot is the entire rotation mechanism.
Procedure
Section titled “Procedure”-
Generate a new secret. 32 bytes of randomness, base64-encoded:
Terminal window openssl rand -base64 32Stash the output in your password manager. You will paste it into two places.
-
Set the new secret as
POLARIS_SECRET_Bon the Worker. From theemail-worker/directory:Terminal window cd email-workernpx wrangler secret put POLARIS_SECRET_B# paste the new value when promptednpx wrangler deployThe Worker now accepts signatures from either
_A(the old key, still in use by the web app) or_B(the new key, not used by anyone yet). No customer-facing traffic changes. -
Roll the new secret into the Fresh app. Update
CF_EMAIL_WORKER_SECRETin your web.env(or your Docker Compose env file / secret store) to the new value, then restart the web container:Terminal window docker compose up -d --force-recreate webOutbound emails are now signed with the new key. Both the old and new signatures verify, so any in-flight sends from a still-running replica are still accepted.
-
Verify. Trigger a real send — easiest is to request a magic link from the customer login page — and confirm in the Worker tail that it succeeded:
Terminal window npx wrangler tailYou should see a
"send ok"line. If you see"hmac mismatch", stop and roll back (see below) — the Fresh app and Worker disagree about the secret. -
Retire the old secret. Once you have confirmed at least one successful send and no requests are still signing with the old key:
Terminal window npx wrangler secret delete POLARIS_SECRET_AAt this point, only
POLARIS_SECRET_Bis configured. You can either leave it that way (and rotate into_Anext cycle) or normalise back to a single-secret_Astate:Terminal window npx wrangler secret put POLARIS_SECRET_A # paste the same value as Bnpx wrangler secret delete POLARIS_SECRET_Bnpx wrangler deployPick one pattern (ping-pong A↔B, or always-normalise-to-A) and document it in your operations runbook so on-call knows which slot is “live.”
Roll back
Section titled “Roll back”If step 4 fails (signature mismatch, web app can’t send):
- Revert
CF_EMAIL_WORKER_SECRETin the web.envto the previous value. - Restart web:
docker compose up -d --force-recreate web. - Leave
POLARIS_SECRET_Bon the Worker — it does no harm — and investigate before retrying.
Because POLARIS_SECRET_A was never touched on the Worker during the
rotation, rollback is a single web-side env change.
Rotate AUTH_SECRET
Section titled “Rotate AUTH_SECRET” AUTH_SECRET required is the BetterAuth signing key.
There is no two-slot mechanism here — rotating it invalidates all
existing sessions atomically.
Procedure
Section titled “Procedure”-
Generate a new secret. Minimum 32 characters:
Terminal window openssl rand -base64 48 -
Announce the rotation if you have an internal ops channel. Active charging sessions are not interrupted (those are tracked by SteVe, not by the web session), but anyone logged into the customer portal or admin console will be signed out.
-
Update the env file with the new
AUTH_SECRETvalue. Keep the old value somewhere retrievable for a few hours in case you need to roll back. -
Restart the web container:
Terminal window docker compose up -d --force-recreate web -
Verify. Open the customer portal in a fresh browser session and complete a magic link sign-in. Then sign into the admin console at
ADMIN_BASE_URL. Both should issue working sessions.
Roll back
Section titled “Roll back”If sign-in is broken after rotation:
- Restore the previous
AUTH_SECRETvalue in the env file. - Restart the web container.
- Sessions issued during the broken window are dead either way — users will need to sign in again — but rollback restores the ability to issue new sessions.
Audit checklist
Section titled “Audit checklist”After each rotation, confirm:
- The new secret is recorded in your password manager / secret store with a rotation date.
- The previous secret has been removed from anywhere it was stored
(including
.env.backupfiles, chat scrollback, ticket attachments). -
wrangler secret listshows only the slots you expect. - At least one real magic-link send has succeeded since the Email Worker rotation.
- At least one fresh sign-in has succeeded on both the customer
and admin surfaces since the
AUTH_SECRETrotation.
What this runbook does not cover
Section titled “What this runbook does not cover”- SteVe HMAC keys (
STEVE_PREAUTH_HMAC_KEY,STEVE_METERVALUE_HMAC_KEY). These are rotated as part of the SteVe webhook configuration; they require coordinated changes on the SteVe side. - OIDC client secret (
ADMIN_OIDC_CLIENT_SECRET). Rotated at your Pocket ID instance, then mirrored into the web.envand the web container restarted. No grace window — sign-ins in flight during the swap will fail and need to retry. - APNs key (
APNS_KEY_BASE64). Issue a new key in App Store Connect, updateAPNS_KEY_IDandAPNS_KEY_BASE64together, restart web. The previous key remains valid until you explicitly revoke it in App Store Connect, so there’s a natural grace period. - Database credentials (
POSTGRES_PASSWORD,DATABASE_URL). Out of scope here — these require coordinated Postgres-side and app-side changes and are best done during a maintenance window.