Security
Operations-side security runbook. Covers the headers layer, where secrets live, how to rotate them, and minimal incident response. Full details on HTTP headers are in docs/SECURITY_HEADERS.md; details on RLS are in docs/architecture/rls.md.
Defense-in-depth layers
- Cloudflare edge — TLS termination, WAF rules (dashboard-configured),
public/_headersandpublic/_redirects. - Browser headers (CSP, HSTS, frame-ancestors, etc.) — applied at
public/_headersin production only. Dev server does not apply them. - Supabase Auth — JWT-based sessions, refresh token rotation enabled (
supabase/config.toml:enable_refresh_token_rotation = true). - RLS — the primary data-layer boundary. Every permission-gated table has an RLS policy that calls
auth_user_has_permission('x.y'). Seedocs/architecture/rls.md. - Application permission checks —
<ProtectedRoute requiredPermissions=[...]>,<PermissionGate permission="...">, andawait requirePermission('x.y')insrc/lib/api.ts. Enforced by the permissions matrix test (seetesting.md). - Edge Function auth — either
verify_jwt = true(default) or an in-function check (HMAC signature for webhooks, bearer token for internal callers).
Tip
RLS is the real boundary. Application checks are UX (hide buttons) and defence-in-depth. If RLS is wrong, a malicious client can bypass every frontend check by calling Supabase directly.
HTTP security headers
Applied via public/_headers to every route in the Cloudflare Pages deploy:
X-Frame-Options: DENY+ CSPframe-ancestors 'none'X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-DNS-Prefetch-Control: offContent-Security-Policy— seedocs/SECURITY_HEADERS.mdfor the full directive-by-directive table.
Verifying after a deploy (condensed from the full doc):
- Deploy a preview/staging branch.
- Open the preview URL; load public site, log in as customer / partner / admin; exercise Google Drive upload and Turnstile.
- Devtools → Console → filter for "Content Security Policy". Expect zero violations.
- Network tab: confirm
Strict-Transport-Security,X-Frame-Options,Content-Security-Policyon the HTML response. - Confirm
cache-control: public, max-age=31536000, immutableon/assets/<hash>.js.
Where secrets live
| Secret | Location | Rotation trigger |
|---|---|---|
VITE_* (frontend build) |
GitHub Actions secrets + Cloudflare Pages env vars | Collaborator departure; suspected leak |
| Supabase service role key | Supabase dashboard only; never in frontend | On compromise |
Edge Function secrets (RESEND_API_KEY, WHATSAPP_*, RAZORPAY_WEBHOOK_SECRET, GOOGLE_DRIVE_*, etc.) |
Supabase project secrets via supabase secrets set |
On compromise; on provider-initiated rotation |
SUPABASE_DB_URL |
GitHub Actions secrets (used by the backup workflow) | Collaborator with admin access leaves; password change |
| AWS backup secrets | GitHub Actions secrets | Standard IAM hygiene |
Canonical list: docs/secrets-setup.md.
Danger
Never commit secrets. env.example holds only public values (anon key, Turnstile site key, Google client ID). The anon key is safe in the frontend because RLS enforces per-row access.
Rotating an Edge Function secret
General pattern (applies to every secret set via supabase secrets set):
- Generate the new secret at the provider (e.g. rotate the Razorpay webhook secret in the Razorpay dashboard).
-
Set the new value in Supabase:
-
Redeploy the function that reads it (so the new value is loaded into the current instance):
-
Verify end-to-end with a real request.
- Revoke the old secret at the provider only after you have confirmed the new value works.
Rotating the Razorpay webhook secret
The razorpay-webhook Edge Function authenticates each call by recomputing HMAC-SHA256(raw_body, RAZORPAY_WEBHOOK_SECRET) and comparing against X-Razorpay-Signature. The function has verify_jwt = false in supabase/config.toml precisely because it does its own auth against this secret.
- In the Razorpay dashboard, open the webhook configuration (Payments → Webhooks → the endpoint pointing at the Supabase Edge Function URL).
- Rotate the webhook secret there. Copy the new value.
-
Update Supabase:
-
Trigger a test webhook from Razorpay (the dashboard has a "Send test webhook" option). Watch Supabase dashboard → Edge Functions → razorpay-webhook → Logs — the request should succeed, not return
server misconfiguredorinvalid signature. - After one or two real payment events confirm the new secret is live, confirm the old value is fully deactivated in Razorpay.
Warning
Do not remove the old value before the function is redeployed. The running Edge Function instance caches env vars at startup; until redeploy, it is validating against the old secret. Payments during that window will fail signature validation.
Rotating SUPABASE_DB_URL
Used only by the backup workflow.
- In Supabase dashboard → Settings → Database, regenerate the database password.
- Compose the new
postgres://...URI with the new password (use the Direct connection tab — not pgbouncer). - GitHub: Settings → Secrets and variables → Actions →
SUPABASE_DB_URL→ Update. - Manually trigger the backup workflow (
gh workflow run supabase-backup.yml) and confirm it succeeds.
Incident response
Baseline playbook. This is a stub; expand it as the team runs real incidents and learns specifics.
Suspected secret leak (key in logs, public repo, Slack screenshot)
- Rotate the secret immediately. Follow the provider-specific steps above.
- Invalidate any sessions if the secret was a Supabase service key: Supabase dashboard → Authentication → Sessions → Revoke all (or per-user).
- Notify CEO / GM — they are the only approvers for incident comms and, where required, customer disclosure.
- Audit access logs in Supabase dashboard and at the provider (Razorpay, Meta, Google Cloud) for unexpected activity.
- Write a post-incident note in the team's ops channel with the timeline and what changed.
Suspicious account activity (impossible travel, brute force, mass data export)
- Identify the user via Supabase Auth → Users.
- Lock the account: set
is_active = falseor ban viaauth.admin.updateUserById({ banned_until: 'permanent' }). - Revoke all sessions for that user (Supabase Auth → that user → Revoke sessions).
- Preserve evidence: export the user's audit trail (activity logs / bookings / finance events) before deleting.
- Page CEO / GM. If customer data may have been exfiltrated, CEO decides on disclosure timing per local regulations.
Production data corruption
- Page CEO / GM.
- Put the app into maintenance mode ().
- Assess scope with direct SQL (via
SUPABASE_DB_URL) before trying to fix with application code. - If the corruption is recent (< 7 days), prefer Supabase PITR over the nightly artifacts — it has lower RPO. See
backup-restore.md. - Post-incident: write a forward migration that prevents the same corruption from recurring (constraint, RLS tightening, application guard).
Routine hygiene
- Quarterly: rotate any secret whose provider does not auto-rotate (WhatsApp access token, Razorpay webhook secret).
- Whenever a collaborator with admin access leaves:
- Remove their GitHub repo access.
- Remove their Supabase project membership.
- Rotate
SUPABASE_DB_URLand any Edge Function secrets they were exposed to. - Rotate Cloudflare Pages deploy credentials if they had dashboard access.
- Annually: review the CSP allowlist in
public/_headersagainst the actually-loaded third parties. Remove anything the app no longer uses.