# Security Research Cache ## Supabase Anonymous Sign-In Security Model _Researched: 2026-04-05 (from knowledge base; web search unavailable)_ - `signInAnonymously()` creates a real auth user in GoTrue with `is_anonymous: true` in the JWT claims - JWT is stored in **localStorage** by default via the Supabase JS client (`@supabase/supabase-js`) - The JWT contains: `sub` (user UUID), `aud`, `role` (defaults to `authenticated`), `is_anonymous: true`, `exp`, `iat`, `session_id` - Anonymous users CAN be linked/upgraded to a permanent identity later via `updateUser()` with email/password - The `is_anonymous` claim in the JWT can be used in RLS policies to distinguish anonymous from linked users - Refresh tokens are also stored in localStorage alongside the access token - The Supabase JS client does NOT support httpOnly cookie storage natively; server-side auth helpers (`@supabase/ssr`) use cookies but they are NOT httpOnly by default -- the JS client needs to read them - Anonymous sessions have the same `authenticated` role as regular users in PostgREST/RLS context - Risk: localStorage tokens are vulnerable to XSS exfiltration; this is a known Supabase design tradeoff ## Self-Hosted Supabase Security Hardening _Researched: 2026-04-05 (from knowledge base; web search unavailable)_ - Self-hosted Supabase uses Kong as the API gateway in front of all services - **Critical defaults that MUST be changed:** - `POSTGRES_PASSWORD` - default is `your-super-secret-and-long-postgres-password` - `JWT_SECRET` - default is a known value; MUST be changed to a unique 32+ char secret - `ANON_KEY` and `SERVICE_ROLE_KEY` must be regenerated using the new JWT_SECRET - `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` for Supabase Studio - `GOTRUE_JWT_SECRET` must match `JWT_SECRET` - `PGRST_JWT_SECRET` must match `JWT_SECRET` - **Supabase Studio** is exposed on port 3000 by default -- this is a full admin dashboard with direct DB access - Must be either: firewalled to localhost only, password-protected, or removed from docker-compose in production - **Kong** exposes the API on port 8000 (HTTP) and 8443 (HTTPS) by default - In production, only the reverse proxy should reach Kong; do not expose Kong ports to the internet - **PostgREST** allows introspection of the database schema by default via OPTIONS requests - Consider restricting schema introspection in production - **Postgres port 5432** should NOT be exposed to the host network - The self-hosted `.env` file from Supabase's GitHub contains placeholder secrets -- ALL must be replaced - GoTrue email confirmation is enabled by default; for anonymous-only auth, this is irrelevant but other auth methods should be disabled - Edge Functions run in a local Deno runtime; ensure the Deno container has no unnecessary network access ## Caddy Automatic HTTPS in Docker _Researched: 2026-04-05 (from knowledge base; web search unavailable)_ - Caddy uses ACME (Let's Encrypt) for automatic certificate provisioning and renewal - In Docker, Caddy needs persistent storage for certificates: mount `/data` and `/config` as volumes - If certificate storage is not persisted, Caddy will request new certificates on every container restart, risking Let's Encrypt rate limits (5 duplicate certs per week) - Caddy handles renewal automatically ~30 days before expiry; no cron needed - **Gotcha**: Caddy must be reachable on ports 80 and 443 from the internet for ACME HTTP-01 challenges - **Gotcha**: Internal service communication (Caddy -> Next.js, Caddy -> Kong) should use HTTP internally, not HTTPS; Caddy terminates TLS at the edge - **Gotcha**: If using Docker networks, services communicate via container names (e.g., `http://nextjs:3000`); no TLS needed on internal network - Caddy supports `on_demand` TLS but this should be disabled for a single-domain deployment - Caddy automatically redirects HTTP to HTTPS - For local development, Caddy can issue self-signed certificates via its internal CA ## iron-session Encryption _Researched: 2026-04-05 (from knowledge base; web search unavailable)_ - iron-session uses the `iron` cryptographic protocol (originally from hapi.js) - Encryption: AES-256-CBC with HMAC-SHA256 for integrity - The "password" (secret) is used to derive encryption and integrity keys via PBKDF2 - **32-character secret**: iron requires a minimum of 32 characters. This provides the input to PBKDF2 key derivation. 32 random characters (alphanumeric) provide ~190 bits of entropy, which is sufficient - The secret is NOT used directly as the AES key; PBKDF2 derives the actual key - iron-session v8+ supports secret rotation: pass an array of secrets, newest first - Cookie payload is: encrypted data + HMAC + IV + salt (all base64url encoded) - Expiry is enforced both in the cookie `maxAge` AND within the encrypted payload (double-checked) - No known CVEs against iron-session or the iron protocol as of early 2026 ## Supabase RLS Policy Pitfalls _Researched: 2026-04-05 (from knowledge base; web search unavailable)_ - **Common mistake 1**: Using `USING` clause but forgetting `WITH CHECK` -- allows reads but silently drops unauthorized writes instead of erroring, or vice versa - **Common mistake 2**: Permissive policies stack with OR logic; restrictive policies stack with AND. Most people want PERMISSIVE but don't realize multiple permissive policies on the same action create an OR (any match allows access) - **Common mistake 3**: Not testing DELETE policies -- users may be able to delete rows they shouldn't - **Common mistake 4**: RLS policies that call functions which query other tables can be slow or have security issues if the called function is `SECURITY DEFINER` (runs as owner, bypasses RLS on the queried table) - **Common mistake 5**: Forgetting to enable RLS on a table -- without `ALTER TABLE ... ENABLE ROW LEVEL SECURITY`, policies are defined but not enforced - **Common mistake 6**: Using `auth.uid()` in policies but the user's JWT has expired -- Supabase returns null for `auth.uid()` on expired tokens, which could match rows where user_id is null - **Common mistake 7**: INSERT policies that don't restrict which `user_id` value can be inserted -- users can insert rows with another user's ID in the `added_by` or `user_id` column - **Common mistake 8**: Not accounting for the `service_role` key bypassing ALL RLS -- any endpoint using the service role key must have its own authorization logic ## 12-Month Auto-Deletion of Inactive Accounts _Researched: 2026-04-05 (from knowledge base; web search unavailable)_ - Key risk: `last_active_at` must be updated reliably on every meaningful activity, not just login - If `last_active_at` is only set on login, a user who stays logged in for months (never closing the tab) would appear inactive - Supabase anonymous auth sessions have refresh tokens; each token refresh could update `last_active_at` - The deletion job must handle cascading correctly: user -> group_members (remove memberships) -> movies (anonymize or nullify `added_by`) -> if user was last admin of a group, what happens to the group? - Race condition: user becomes active between the job's SELECT and DELETE -- use a transaction with `FOR UPDATE` or re-check `last_active_at` in the DELETE WHERE clause - Cannot notify anonymous users before deletion (no email) -- documented in scope, which is correct - The job should run infrequently (daily or weekly) and log all deletions for audit ## WORD-WORD Invite Code Entropy _Researched: 2026-04-05 (from knowledge base; web search unavailable)_ - Entropy depends entirely on the word list size - Common word lists: EFF short wordlist (1296 words = 6^4), EFF long wordlist (7776 words = 6^5), BIP39 (2048 words) - WORD-WORD with 2048 words = 2048^2 = 4,194,304 combinations (~22 bits of entropy) - WORD-WORD with 7776 words = 7776^2 = 60,466,176 combinations (~26 bits of entropy) - At 5 attempts per window with rate limiting, brute force becomes impractical even at 22 bits: - 4.2M combinations / 5 per 15 min = 12.6M minutes = ~24 years - Without rate limiting, 4.2M codes could be tried in minutes - The word list should avoid offensive words, easily confused words, and be culturally neutral - Only ONE invite code is valid per group at any time, so the attacker must find the specific active code - Comparison: Zoom meeting IDs are 9-11 digits (~30-37 bits) with waiting room as secondary control ## Argon2id Parameters for Docker _Researched: 2026-04-05 (from knowledge base; web search unavailable)_ - OWASP 2024 recommendation for Argon2id: memory=19456 KiB (19 MiB), iterations=2, parallelism=1 - RFC 9106 recommends: first choice memory=2 GiB, iterations=1, parallelism=4; second choice memory=64 MiB, iterations=3, parallelism=4 - For a resource-constrained Docker container (512MB-1GB RAM typical): - memory=64 MiB per hash is too high if multiple concurrent requests hash simultaneously - Recommended: memory=19456 KiB (19 MiB), iterations=2, parallelism=1 (OWASP recommendation) - This uses ~19 MiB per hash operation; with a concurrency limit of 4, that's ~76 MiB - Salt length: minimum 16 bytes (128 bits); Argon2id generates this automatically in most libraries - Output hash length: 32 bytes (256 bits) is standard - Node.js library: `argon2` (npm) wraps the reference C implementation - Key concern: if the Docker container has a hard memory limit and Argon2id memory parameter is too high, the hash operation will OOM-kill the container - Recovery code hashing is infrequent (only on creation and claim), so higher memory cost is acceptable compared to password hashing on every login