> ## Documentation Index
> Fetch the complete documentation index at: https://handbook.polar.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Rotate the Token Hashing Secret

> Add a secret, move the pointer, let credentials migrate, retire the old one.

<Warning>
  Production. This secret hashes every session, API token and one-time code.
</Warning>

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](https://dashboard.render.com/) 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.

```bash theme={null}
aws secretsmanager get-random-password \
  --password-length 64 --exclude-punctuation \
  --query RandomPassword --output text \
| aws secretsmanager put-secret-value \
    --secret-id polar-production-hash-secret \
    --version-stages s2 \
    --secret-string file:///dev/stdin
```

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

```bash theme={null}
aws secretsmanager list-secret-version-ids \
  --secret-id polar-production-hash-secret \
  --query 'Versions[].[VersionId,VersionStages]' --output table
```

```bash theme={null}
aws secretsmanager update-secret-version-stage \
  --secret-id polar-production-hash-secret \
  --version-stage AWSCURRENT \
  --move-to-version-id <new version id> \
  --remove-from-version-id <old version id>
```

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

```sql theme={null}
WITH retiring AS (SELECT 's1$%' AS prefix)
SELECT 'user_sessions' AS source, count(*) FROM user_sessions, retiring WHERE token_v2 LIKE prefix
UNION ALL SELECT 'customer_sessions', count(*) FROM customer_sessions, retiring WHERE token_v2 LIKE prefix
UNION ALL SELECT 'member_sessions', count(*) FROM member_sessions, retiring WHERE token_v2 LIKE prefix
UNION ALL SELECT 'customer_session_codes', count(*) FROM customer_session_codes, retiring WHERE code_v2 LIKE prefix
UNION ALL SELECT 'personal_access_tokens', count(*) FROM personal_access_tokens, retiring WHERE token_v2 LIKE prefix
UNION ALL SELECT 'organization_access_tokens', count(*) FROM organization_access_tokens, retiring WHERE token_v2 LIKE prefix
UNION ALL SELECT 'oauth2_tokens.access', count(*) FROM oauth2_tokens, retiring WHERE access_token LIKE prefix
UNION ALL SELECT 'oauth2_tokens.refresh', count(*) FROM oauth2_tokens, retiring WHERE refresh_token LIKE prefix
UNION ALL SELECT 'oauth2_authorization_codes', count(*) FROM oauth2_authorization_codes, retiring WHERE code LIKE prefix
ORDER BY 1;
```

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.

```bash theme={null}
uv run python -m scripts.rehash_oauth2_client_secrets --execute
```

## 7. Retire it

<Warning>
  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.
</Warning>

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.

```bash theme={null}
aws secretsmanager update-secret-version-stage \
  --secret-id polar-production-hash-secret \
  --version-stage s1 \
  --remove-from-version-id <old version id>

aws secretsmanager update-secret-version-stage \
  --secret-id polar-production-hash-secret \
  --version-stage AWSPREVIOUS \
  --remove-from-version-id <old version id>
```

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.
