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