// security

How This Site Is Secured

Last updated: August 18, 2026

1. Architecture & data flow

  • $Browser → Cloudflare Workers (Astro SSR) → Supabase (Postgres + Auth + RLS) + Cloudflare KV
  • $Hybrid rendering: static pages (prerender, via Static Assets) & dynamic pages (SSR) in a single deployment
  • $KV is used for login sessions & rate limiting; the certificate storage bucket is private
  • $TLS transport + HSTS preload on every layer; domain served behind the Cloudflare edge

2. Security headers (9 active)

  • $Content-Security-Policy — default-src 'self'; no 'unsafe-inline'/'unsafe-eval'; frame-ancestors 'none'; upgrade-insecure-requests
  • $Strict-Transport-Security — max-age 2 years; includeSubDomains; preload
  • $X-Frame-Options: DENY
  • $X-Content-Type-Options: nosniff
  • $Referrer-Policy: strict-origin-when-cross-origin
  • $Permissions-Policy — camera, microphone, geolocation, payment disabled
  • $Cross-Origin-Opener-Policy: same-origin — anti tab-nabbing
  • $Cross-Origin-Resource-Policy: same-origin
  • $Origin-Agent-Cluster: ?1 — process isolation
  • $Enforced on two layers: Worker middleware (SSR responses) + _headers (static assets) — kept in sync

3. Authentication & authorization

  • $PKCE login; MFA TOTP MANDATORY for admin/editor — enforced at the DATABASE level via RLS policies (jwt aal2), not just the UI
  • $Three-role RBAC (admin/editor/viewer) with deny-by-default on two layers: application & Postgres
  • $Layered rate limiting: per-IP and per-IP+email; progressive lockout 15 min → 4 h; MFA endpoint: 10 attempts per 10 minutes
  • $Fail-closed mode when KV is unavailable: login is rejected (deliberate decision — see §5)
  • $Account enumeration resistance: generic error messages, email addresses not logged on block

4. Data protection

  • $Deny-by-default RLS on every table; certificate storage bucket is private (no files served publicly)
  • $Automated 3-2-1 backup every week: 2 off-site copies (GitHub artifact + Google Drive), encrypted GPG AES-256, separate keys
  • $Retention: contact messages 12 months; audit logs 90 days; Drive copies 12 weeks (automatic rotation)
  • $RTO ≤ 1 day, RPO ≤ 7 days — restore runbook & drills documented

5. Design decisions (and their trade-offs)

  • $Fail-closed rate limiting: if KV goes down, login is denied — the Self-DoS risk is accepted deliberately because public pages keep serving (fail-open) and the Supabase Dashboard remains an emergency path
  • $MFA at the DB level, not just the UI: leaked credentials stay useless even if application code is bypassed
  • $Zero visitor tracking: no third-party analytics — privacy as the default, not a feature
  • $No COEP/SRI: COEP would block external resources lacking CORP (Turnstile, supabase.co); Turnstile provides no stable SRI hash — risk outweighs benefit at this scale

6. Concise threat model (STRIDE)

  • $Spoofing → PKCE + TOTP MFA (aal2) + deny-by-default RBAC
  • $Tampering → HSTS preload, strict CSP, same-origin CORP, GPG-encrypted backups
  • $Repudiation → audit logs for login & backup config access (90-day retention)
  • $Information disclosure → deny-by-default RLS, private bucket, strict Referrer-Policy
  • $Denial of service → fail-closed rate limiting, progressive lockout, Cloudflare Turnstile on the public form
  • $Elevation of privilege → two-layer role checks (application + RLS aal2 policies)

7. Responsible disclosure

  • $security.txt (RFC 9116): https://portofolio.indyadirak.my.id/.well-known/security.txt
  • $Report a vulnerability: https://github.com/indyadirak/website-portofolio/security/advisories/new
  • $Security policy (SECURITY.md): https://github.com/indyadirak/website-portofolio/blob/main/SECURITY.md
  • $Audit & compliance mapping documentation is public in the repository (docs/SECURITY_COMPLIANCE_MAPPING.md)