Skip to content

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.

  • 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.

The Worker accepts a signature from either POLARIS_SECRET_A or POLARIS_SECRET_B. That second slot is the entire rotation mechanism.

  1. Generate a new secret. 32 bytes of randomness, base64-encoded:

    Terminal window
    openssl rand -base64 32

    Stash the output in your password manager. You will paste it into two places.

  2. Set the new secret as POLARIS_SECRET_B on the Worker. From the email-worker/ directory:

    Terminal window
    cd email-worker
    npx wrangler secret put POLARIS_SECRET_B
    # paste the new value when prompted
    npx wrangler deploy

    The 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.

  3. Roll the new secret into the Fresh app. Update CF_EMAIL_WORKER_SECRET in 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 web

    Outbound 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.

  4. 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 tail

    You 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.

  5. 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_A

    At this point, only POLARIS_SECRET_B is configured. You can either leave it that way (and rotate into _A next cycle) or normalise back to a single-secret _A state:

    Terminal window
    npx wrangler secret put POLARIS_SECRET_A # paste the same value as B
    npx wrangler secret delete POLARIS_SECRET_B
    npx wrangler deploy

    Pick 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.”

If step 4 fails (signature mismatch, web app can’t send):

  1. Revert CF_EMAIL_WORKER_SECRET in the web .env to the previous value.
  2. Restart web: docker compose up -d --force-recreate web.
  3. Leave POLARIS_SECRET_B on 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.

AUTH_SECRET required is the BetterAuth signing key. There is no two-slot mechanism here — rotating it invalidates all existing sessions atomically.

  1. Generate a new secret. Minimum 32 characters:

    Terminal window
    openssl rand -base64 48
  2. 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.

  3. Update the env file with the new AUTH_SECRET value. Keep the old value somewhere retrievable for a few hours in case you need to roll back.

  4. Restart the web container:

    Terminal window
    docker compose up -d --force-recreate web
  5. 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.

If sign-in is broken after rotation:

  1. Restore the previous AUTH_SECRET value in the env file.
  2. Restart the web container.
  3. 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.

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.backup files, chat scrollback, ticket attachments).
  • wrangler secret list shows 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_SECRET rotation.
  • 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 .env and 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, update APNS_KEY_ID and APNS_KEY_BASE64 together, 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.