AES-256 key. Must be exactly 32 characters. Used to encrypt WhatsApp tokens and patient DUI numbers. Generate with openssl rand -hex 16 (gives 32 hex chars).
Frontend origin for CORS. e.g. https://app.clinicflow.lat. No trailing slash.
BACKEND_URL
No
Backend URL used in internal references.
BACKEND_PUBLIC_URL
Yes
Public backend URL used in ICS calendar feed URLs. e.g. https://api.clinicflow.lat
REFRESH_COOKIE_DOMAIN
No
Registrable domain shared by frontend + API so the refresh-token cookie is sent cross-subdomain (e.g. .clinicflow.lat). Leave unset for localhost (host-only cookie).
Comma-separated operator:key[:expiresAt] entries for the superadmin API (e.g. ernesto:abc123...:2026-12-31). Sent as X-Admin-Key header. Each key >=16 characters. expiresAt is optional (ISO date). Generate keys with openssl rand -hex 24.
SUPER_ADMIN_IP_ALLOWLIST
No
Comma-separated exact IPs allowed to present a superadmin key. Unset = no restriction (default).
OWNER_WHATSAPP_NUMBER
No
Owner’s WhatsApp number in E.164 format. Used for demo system notifications.
Which chatbot LLM provider to route to: anthropic or gemini. Defaults to anthropic.
ANTHROPIC_API_KEY
Yes (if LLM_PROVIDER=anthropic)
Anthropic API key for Claude Haiku chatbot. Get from console.anthropic.com
GEMINI_API_KEY
Yes (if LLM_PROVIDER=gemini)
Google Gemini API key for the Gemini 2.5 Flash chatbot alternative.
GROQ_API_KEY
No
Groq key for Whisper (whisper-large-v3-turbo) transcription of WhatsApp voice notes — preferred provider (cheaper + newer). If unset, voice notes degrade gracefully (the bot asks the patient to type). Get from console.groq.com
OPENAI_API_KEY
No
Fallback transcription provider (whisper-1) used only when GROQ_API_KEY is absent. Get from platform.openai.com
TRANSCRIPTION_URL / TRANSCRIPTION_MODEL
No
Override the transcription endpoint/model for any other OpenAI-compatible Whisper host. Defaults to Groq when GROQ_API_KEY is set.
The format already supports multiple concurrent operator keys
(operator:key[:expiresAt], comma-separated) — rotation needs no dual-window logic:
Add the new key as a new entry (new operator name, or the same operator with a new key
value) alongside the existing one.
Deploy. Both keys now work.
Confirm the new key works (X-Admin-Key header on any /api/superadmin/* route).
Remove the old entry, deploy again.
To pre-schedule a cutover instead of two deploys, set the old key’s expiresAt (ISO date) to
the cutover date in the same deploy that adds the new key — expired keys fail closed
automatically (backend/src/config/superAdminKeys.ts’s matchSuperAdminOperator).
Not a simple env-var swap.encrypt()/decrypt() derive their AES-256-GCM and
HMAC blind-index keys via HKDF from this single master secret
(backend/src/utils/encryption.ts) — there is no key-versioning scheme, so every existing
v3:-prefixed ciphertext (patients.email/notes, conversation_messages.body/
patient_phone, chat_sessions.patient_phone, whatsapp_outbox.phone/body,
audit_logs.changes, periogram_snapshots.data_payload, calendar_feed_token) becomes
unreadable the instant the key changes.
Procedure — mirrors the one-time backend/scripts/backfill-track2-encryption.ts migration
(which did the same shape of work for the v2: → v3: format change):
Write a one-off script that, for each affected table/column: reads the row with the
oldENCRYPTION_KEY loaded, decrypts, holds the plaintext in memory only as long as
needed, re-encrypts with the new key, writes back.
Run it during a low-traffic window. decrypt() fails closed (returns null, never
throws), so any row not yet migrated degrades to a blank field rather than a 500 — but
that’s still a real availability gap for that row until the script reaches it, so don’t
treat this as zero-downtime.
Only after the backfill completes, update ENCRYPTION_KEY in Railway and redeploy.
There is no dual-key support for this secret today — that’s the biggest gap versus
SUPER_ADMIN_KEYS. If scheduled/recurring rotation becomes a requirement, that’s a code
change (versioned ciphertext prefix + multi-key decrypt()), not a runbook step.
These are Supabase’s own Auth (GoTrue) signing key, reused so the app’s own session JWTs
verify against Supabase’s role: authenticated/RLS checks directly. verifyUserToken()
(backend/src/auth/userToken.ts) checks the token’s kid against exactly one configured
JWT_KEY_ID — no dual-kid verification window in the app code.
In the Supabase Dashboard → Authentication → JWT Keys, generate/rotate the signing
key and copy its new JWK pair + kid.
Update JWT_PRIVATE_KEY, JWT_PUBLIC_KEY, and JWT_KEY_ID together in Railway (all
three must match the same key generation) and redeploy.
Any access token signed with the old kid now fails verification — but refresh tokens
are opaque and DB-backed (refresh_tokens table, SHA-256 hash, no JWT involved), so the
very next API call from an affected session gets one 401, the frontend’s axios
interceptor (frontend/src/services/api.ts) silently calls /api/auth/refresh, and the
response carries a token signed with the new key. No re-login required.
Access tokens are short-lived — USER_TOKEN_TTL_SECONDS in
backend/src/auth/userToken.ts hardcodes 8 hours (not env-configurable; JWT_EXPIRES_IN
appears in .env.test but nothing reads it) — so old-kid tokens drain from circulation
within one 8-hour window regardless.