Key Rotation Plan
How Secret Updates Work
All secrets are Cloudflare Worker secrets loaded via env.secret() at the start of each Worker request or Durable Object invocation. When a secret is rotated via wrangler secret put:
- In-flight requests continue using the old secret until they complete
- The next request to the Worker picks up the new secret
- Durable Objects must be evicted/shut down before they receive updated secrets — the next alarm or request after eviction uses the new secret
- Retrying within the same request will NOT get the new secret — the retry must come from the caller (mobile app, next alarm, queue retry)
Rotation Resilience Strategy
| Mechanism | How it handles rotation |
|---|---|
Mobile app withRetry() | Retries once on 500/502 after 2s delay — new request gets fresh secret |
| DO alarm scheduling | Failed alarm records error and reschedules — next alarm is a fresh invocation |
| Email queue | Failed send triggers message.retry() — queue consumer is a fresh invocation |
| Fire-and-forget logging | Acceptable to lose a few log lines; DO logger retains buffer on auth failure |
| User-initiated actions | User retries by clicking again — new request gets fresh secret |
Rotation Script
Use scripts/rotate-secrets.sh to deploy secrets to all relevant workers in parallel:
# Rotate a specific secret
./scripts/rotate-secrets.sh service-auth --env production
# Rotate all secrets interactively
./scripts/rotate-secrets.sh all --env production
Per-Secret Details
SERVICE_AUTH_SECRET / AUTH_SERVICE_AUTH_SECRET
Workers: bf_resident, bf_notify, bf_smart, bf_user, bf_storage, bf_email_forwarder, bf_auth
All workers must be updated simultaneously to avoid auth mismatches. The rotation script handles this by deploying in parallel.
- Service tokens are 5-minute-lived JWTs (HS256)
- The mismatch window is the time between the first and last worker picking up the new secret
- DO alarm tasks (lifecycle checks) are resilient via alarm rescheduling
- Mobile app
withRetry()covers transient 500 errors from service auth failures
YALE_API_KEY
Workers: bf_resident, bf_notify, bf_user, bf_smart
| Call origin | Resilience |
|---|---|
| Mobile → device control (lock/unlock) | withRetry() retries once on 500/502 |
| DO alarm → device refresh (hourly) | Alarm reschedules on failure, next alarm gets new secret |
| DO alarm → activity log refresh | Same as above |
| Yale → bf_notify webhook verification | Yale re-sends failed webhooks |
| Web app → IT setup (manual admin action) | User retries manually |
CLOUDFLARE_TURN_KEY_ID / CLOUDFLARE_TURN_KEY_TOKEN
Workers: bf_resident only
Mobile VideoFeedPlayer retries TURN credential fetch once before falling back to STUN-only. Single handler, single worker.
POSTHOG_API_KEY
Workers: bf_resident, bf_notify, bf_user
| Component | Resilience |
|---|---|
| bf_resident / bf_notify (fire-and-forget) | Acceptable loss, console.log fallback exists |
| bf_user DO logger (buffered flush) | Retains buffer on 401/403, next flush in fresh invocation retries |
OPENROUTER_API_KEY
Workers: bf_auth, bf_resident
Both usages (verification code extraction, floor plan analysis) are user-initiated with existing 3-attempt retry. While retries within the same request use the stale secret, the user can re-trigger the action, which creates a new request with the fresh secret.
RESEND_API_KEY
Workers: bf_resident only
Emails are sent via the EMAIL_QUEUE (Cloudflare Queue). The queue consumer loads RESEND_API_KEY fresh on each invocation. Failed messages are retried by the queue (up to 3 retries), with each retry being a new Worker invocation that picks up the new secret. Dead-letter queue (email-queue-dlq) catches permanently failed messages.
CLERK_SECRET_KEY
Workers: bf_resident only
Used for invite CRUD and admin management — all user-initiated synchronous actions. If the secret is stale, the operation fails and the user retries by clicking again. The next request gets the new secret.
Note: Rotate in the Clerk Dashboard first, then deploy the new secret via wrangler secret put.
BF_ENCRYPTION_KEY
Do NOT rotate. This key encrypts stored data. Rotation would make existing encrypted data unreadable without a migration.
Mobile OTA Keys (BF_MOBILE_OTA_PRIVATE_KEY)
Workers: bf_resident only
OTA bundles are signed with an Ed25519 private key on the backend and verified by the mobile app using public keys served from a JWKS endpoint.
Cross-environment design (intentional):
Unlike most secrets, OTA signing keys are shared across staging and production by design. Mobile apps — regardless of which backend they point at — trust the same JWKS, so a bundle signed in staging verifies in production and vice versa. The trust set is single-sourced to avoid fragmenting the mobile app's trust anchors per environment.
To enforce "one writer" for the shared JWKS, the registration endpoint is only compiled into the staging worker:
- Staging (
bf-resident-staging) — built with the defaultmockfeature. It exposesPOST /mobile/keys, owns writes to theota_signing_keysD1 table, and publishes the JWKS to the sharedASSETSR2 bucket. This is the single source of truth for the trusted key set. - Production (
bf-resident) — built with--production(nomockfeature). ThePOST /mobile/keysroute is compiled out. Production only signs bundles withBF_MOBILE_OTA_PRIVATE_KEY; it never touches the JWKS. - Shared artifacts — the
ASSETSR2 bucket (served athttps://assets.broadfordlife.com/.well-known/jwks.json) and theBF_MOBILE_OTA_PRIVATE_KEYsecret value are the same across both environments. Staging writes, both envs sign and verify.
Architecture:
- Public keys are stored in the
ota_signing_keysD1 table (staging only) with akidandcreated_attimestamp —crates/bf_resident_db/migrations/008_ota_signing_keys.sql POST /mobile/keys(crates/bf_resident/src/handlers/ota.rs, gated by#[cfg(feature = "mock")]→ staging-only; protected byMOBILE_SERVICE_AUTH_SECRET) accepts a new public key, inserts it into the database, rebuilds the JWKS file from all keys created in the last 14 days, and uploads it to the sharedASSETSR2 bucket at.well-known/jwks.json- The JWKS is served publicly at
https://assets.broadfordlife.com/.well-known/jwks.jsonand consumed by all mobile clients (staging + production + dev builds pointed at a real backend) - The mobile app fetches and caches the JWKS at startup (with ETag-based conditional requests), storing the cache in the app's data directory —
crates/bf_mobile/src/ota.rs(fetch_and_cache_jwks) - OTA signature verification tries all keys from the cached JWKS; if no JWKS keys are available (fetch failed or dev environment), it falls back to the compile-time embedded
BF_MOBILE_OTA_PUBLIC_KEYin.env - In dev environment, the JWKS fetch is skipped entirely
Rotation procedure:
./scripts/rotate-secrets.sh ota
The script offers two options:
- Generate a new Ed25519 keypair automatically (via
openssl) - Provide your own private key and public key (base64-encoded)
It then:
- Deploys the private key (as a 64-byte seed+pubkey pair) to
bf-residentviawrangler secret put. Run once with--env stagingand once with--env productionso both environments sign with the matching key. - Registers the public key via
POST /mobile/keysagainst staging (app-staging.broadfordlife.com), which writes the authoritative JWKS to the shared R2 bucket. The script also POSTs toapp.broadfordlife.comfor legacy/symmetry, but that route is compiled out in production and will return 404 — this is expected and the script treats it as a non-fatal warning. Staging alone is what actually updates the JWKS that prod clients consume.
Resilience: The 14-day key window means old and new keys coexist in the JWKS. Bundles signed with the old key remain verifiable until the old key ages out. The mobile app caches the JWKS locally, so even if the fetch fails on a subsequent launch, the last-known keys are used. The compile-time fallback key provides a safety net for apps that have never successfully fetched the JWKS.
Queue Setup
Before first rotation involving email, ensure the queues exist:
# Production
wrangler queues create email-queue
wrangler queues create email-queue-dlq
# Staging
wrangler queues create email-queue-staging
wrangler queues create email-queue-staging-dlq