// 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)