Skip to content

Secret Rotation Runbook

Use this when you have committed (or might have committed) a real secret. Time-to-rotate target: under 30 minutes from discovery.

The last1.id repo previously contained live values for several secrets in .env.example. Even though the repo is private, anyone with repo read access (collaborators, GitHub Apps, CI runners) had effectively unlimited control of the Supabase project until rotation. Treat all values that were ever committed as burned.

This runbook covers each secret type, in the order you should rotate.


0. Pre-flight (2 min)

# Confirm the current scrubbed .env.example doesn't contain anything
# that looks like a real JWT (starts with eyJ) or live Turnstile key.
rg -n 'eyJ[A-Za-z0-9_-]{20,}|0x[0-9A-Fa-f]{20,}' .env.example
# Expected: no matches.

If anything matches, scrub it before continuing.


1. Supabase service_role + anon JWTs (10 min) — HIGHEST PRIORITY

The service_role key bypasses RLS entirely — whoever holds it owns every row in every table. The anon key is lower risk but still scope for client-side denial-of-service / quota abuse if leaked.

Steps

  1. Rotate keys on last1id-db (R940), not a hosted dashboard. HISTORICAL: hosted project nztgswcpnwggymncrnyx is deleted. Do not open supabase.com/dashboard/project/nztgsw....
  2. Mint new service_role + anon JWTs from the sovereign JWT secret.
  3. Write the new values to BWS project last1id (LAST1ID_DB_PROD_* / the live GoTrue key names). Never Railway.
  4. Redeploy last1.id from the sovereign unit so auth.last1.id picks up the new keys.
  5. last1-admin operator-auth is a separate plane (api.last1.app). Do not point it at last1id-db.
  6. Smoke test:
    curl https://auth.last1.id/api/health
    

Blast radius if delayed

Anyone with the old service_role key keeps full DB control until rotated. Even after rotation, audit auth_audit_logs and the admin_audit_logs tables for anything suspicious from before rotation.


2. Cloudflare Turnstile (3 min)

Turnstile keys gate the human_verified credential. A leaked secret lets an attacker forge "human verified" claims, which then propagate as Verifiable Credentials.

Steps

  1. Go to https://dash.cloudflare.com/?to=/:account/turnstile
  2. Find the last1.id site, rotate the secret key.
  3. Update Railway: railway variable set TURNSTILE_SECRET_KEY="<new>".
  4. The site key (VITE_TURNSTILE_SITE_KEY) is public by design — no rotation needed unless you also rotate the secret half.
  5. Smoke test: submit a turnstile-gated flow (signup, phone verify).

3. Twilio Verify (3 min)

Twilio Auth Token signs SMS verification requests. Leaked → attacker can impersonate your Verify service and issue codes / drain quota.

Steps

  1. Go to https://console.twilio.com/.
  2. Account → API keys & tokens → rotate the auth token.
  3. Update Railway: railway variable set TWILIO_AUTH_TOKEN="<new>".
  4. Verify Service SID is not a secret (lives in the URL); no rotation.

4. Partner app secrets (5 min each — repeat per partner)

LASTVET_APP_SECRET, RADIOCHECK_APP_SECRET, MATR_IOS_APP_SECRET, MATR_BIZ_APP_SECRET — these gate the cross-app bearer auth path in server/lib/appClientAuth.ts. A leaked secret lets the leaker post arbitrary trust events on behalf of that partner.

Steps (using RadioCheck as example)

  1. Generate a new secret:
    openssl rand -hex 32
    
  2. Update Railway for last1.id:
    railway variable set RADIOCHECK_APP_SECRET="<new>"
    
  3. Update the partner's own secret store (BWS / sovereign env). HISTORICAL: do not supabase secrets set --project-ref tcthzakppndkykatoefx (hosted RadioCheck/last1.app project is deleted).
  4. If the partner reads from Supabase Vault (the RadioCheck cron does), also rotate the Vault entry:
    select vault.update_secret(
      (select id from vault.secrets where name='radiocheck_app_secret'),
      '<new>'
    );
    
  5. Trigger a redeploy on the consumer side so it picks up the new env.
  6. Smoke test by sending one event through the partner's outbox.

5. CREDENTIAL_SIGNING_KEY (DEFER — non-trivial)

This is the KEK that AES-256-GCM-encrypts issuer private keys at rest in the issuer_keys table. Rotating it requires re-wrapping every row in issuer_keys with the new KEK. Do not rotate this without a dedicated maintenance window and the runbook in docs/SECURITY_COMPLIANCE_PACK.md.

If you believe the KEK was actually exposed (e.g. committed publicly), you must rotate. Until then, treat as a planned-maintenance task and add it to the key-rotation handoff doc for the dev being hired.


6. OAUTH_JWT_PRIVATE_KEY (DEFER — coordinate with JWKS rotation)

The id_token signing key. Rotation requires either:

  • A maintenance window where all OIDC tokens are invalidated, or
  • A multi-key JWKS publication so old tokens verify against the old key while new tokens use the new key during the overlap.

Tracked separately as Tier 3 / dev-handoff work.


7. ADMIN_GATEWAY_SECRET / IDENTITY_EXPORT_SIGNING_SECRET / COMPLIANCE_BUNDLE_SIGNING_SECRET

Lower-risk than the Supabase keys but still rotate quarterly. Same pattern: generate via openssl rand -hex 32, update Railway, redeploy.


8. After rotation — verification & cleanup

  1. Audit logs — scan for activity from before rotation that looks suspicious:
    select * from admin_audit_logs
      where created_at < now()
      order by created_at desc limit 200;
    
  2. Git history scrub (optional but recommended) — if you want the old values out of git log entirely:
    # Requires git-filter-repo: pip install git-filter-repo
    git filter-repo --invert-paths --path .env.example
    # Then force-push and have all collaborators re-clone.
    
    If a history rewrite is too disruptive, document the rotated keys as "burned forever" in your runbook and move on.
  3. Update this file with the rotation date and the operator who performed it (below).

Rotation log

Date Operator Secrets rotated Reason
2026-05-21 pending Supabase service_role + anon JWTs, Turnstile, Twilio Discovered live values in committed .env.example; repo is private but anyone with repo read had effective DB control