Production. This secret hashes every session, API token and one-time code.
Each version of polar-production-hash-secret is one secret, and three staging labels matter:
- its own id, which prefixes every hash it computes
AWSCURRENT, on the secret new hashes use
LEGACY, on the secret behind the bare digests written before the first rotation
Never move or retire LEGACY. Moving it breaks every credential hashed before the first
rotation until you move it back. Retiring it breaks them permanently: these tables store
hashes, and nothing can recompute them.
Prerequisites
- AWS access to the environment’s account
- Render Dashboard to redeploy the API
- A psql session on the production database
1. Add the secret
Pick a short id, up to 15 characters. Not a date: that caps rotation at one per period.
Passing --version-stages keeps AWSCURRENT where it is, so nothing writes with the new
secret yet.
2. Deploy
Manual Deploy → Deploy latest reference on the API and the worker. Each process fetches the
set once at startup, so both must see the new secret before it becomes current.
3. Move the pointer
4. Deploy again
New hashes now carry s2$. Log in: the session cookie’s row must show the new prefix.
5. Watch the tail
Credentials migrate when they are used. Run this until the counts stop falling, editing the
prefix on the first line only.
The legacy secret writes no prefix, so its tail is NOT LIKE '%$%' on the same columns.
6. Rehash the OAuth2 clients
This script recomputes client_secret_hash and registration_access_token_hash. Lookups read
both columns, but none can rewrite a stale hit: check_client_secret is a model method and
holds no session.
It decrypts every row it rewrites, one KMS call per secret. On the current table that runs for
hours, so start it early in the rotation.
7. Retire it
No delay makes this safe. Organization access tokens and OAuth2 refresh tokens never
expire, so they migrate only when someone uses them, and one nobody uses never migrates.
Retiring is a judgement call. Rerun step 5 until the counts stop falling, then decide whether
the remainder is acceptable to break. Sizes as of September 2026, largest first:
- Organization access tokens. 24800 of 31667 that never expire have not been used in over
ten days. The merchant issues a new one, by hand, after their integration has already stopped.
- Dormant OAuth2 integrations. 18413 of 21629 live refresh tokens. The row holds no
plaintext, so nothing can migrate one nobody uses. They go back through the authorization
flow.
- Backup codes. 835 enrolments. Only the code someone enters gets rewritten.
- Personal access tokens. Around 199. There is no creation path, so the ones never used
again cannot be reissued at all.
organization_access_tokens.last_used_at and oauth2_tokens.issued_at name who is still using
one, and who to warn before you retire.
A wait drains access tokens and nothing else: the longest expires_in in use is 864000, so
any issued before step 3 are gone ten days later.
Removing both staging labels leaves the version unlabelled, which drops it from the set.
Then redeploy, and rerun step 5: every count must be zero.
Never do this to the version carrying LEGACY. Retiring it needs the legacy tail at zero
everywhere and a code change, since the fetch refuses a secret with no LEGACY version.
Do not schedule rotation
Secrets Manager can rotate on a timer. It would retire versions on that timer too, and AWS
cannot see the tail query that says when that is safe.