Audited: 2026-04-05 | Auditor: Claude (automated) | Type: Pre-implementation architecture and design review
Note: This is a design-phase review based on PROJECT_SCOPE.md. No source code exists yet. All findings are architectural risks and recommendations to be addressed during implementation.
PROJECT_SCOPE.md:Data Model — The scope defines five database tables (users, groups, group_members, movies, landing_reel_posters, admin_sessions) but does not mention Supabase Row Level Security (RLS) policies anywhere. Supabase exposes a PostgREST API directly to the client using the SUPABASE_ANON_KEY. Without RLS policies, any client with the anon key can read, insert, update, and delete ALL rows in ALL tables. This means any user could: read every group's movie list, delete other groups' data, modify other users' accounts, escalate their own role to admin, or forge the added_by field. This is the single most critical security gap in the design. Supabase's anon key is meant to be public — RLS is what enforces authorization.
Fix: Before writing any application code, define and enable RLS policies on every table:
users: Users can only read/update their own row. No user can delete another user.groups: Only members of a group can read it. Only the creator can update/delete.group_members: Users can only read memberships for groups they belong to. Only admins can insert (via invite validation server-side) or delete members. Users can delete their own membership (leave).movies: Only members of the movie's group can read/insert/update/delete movies in that group.landing_reel_posters: Public read, no client write (server-only inserts via service role key).admin_sessions: No client access whatsoever (server-only via service role key).
Enable RLS on every table with ALTER TABLE [table] ENABLE ROW LEVEL SECURITY; and write explicit policies. Do NOT use permissive catch-all policies.Implementation Risk: RLS policies that are too restrictive will break real-time subscriptions and legitimate queries. Test every policy against the actual query patterns the app uses. Supabase real-time respects RLS, so subscriptions will only receive rows the user is authorized to see — but only if policies are correctly written. Overly complex policies can also degrade query performance.
PROJECT_SCOPE.md:329 — The scope lists SUPABASE_ANON_KEY as an environment variable and the architecture implies direct client-to-Supabase communication. The Supabase anon key is designed to be public, but it provides full PostgREST API access constrained only by RLS policies. If RLS is not implemented (Finding 1), the anon key becomes a master key to the entire database. Even with RLS, the anon key allows any authenticated or anonymous client to call any Supabase API endpoint, including auth endpoints, storage endpoints, and edge functions. An attacker who understands the Supabase URL and anon key (both extractable from the client-side JavaScript bundle) can craft arbitrary SQL-like queries via PostgREST.
Fix: (a) Implement comprehensive RLS policies (see Finding 1). (b) For sensitive operations (user deletion, group deletion, admin actions, invite code validation, recovery code claims), use Next.js Server Actions or API routes that call Supabase with the service_role key (never exposed to the client). The service role key bypasses RLS and must never appear in client-side code. (c) Restrict the anon key's permissions to the minimum needed: read-only on public tables, insert-only on specific tables, and no direct update/delete from the client for sensitive columns like role in group_members.
Implementation Risk: Moving operations to server-side API routes increases latency slightly. Ensure the service role key is only used in server-side code (Next.js API routes, Server Actions, or server components) and never imported in client components.
PROJECT_SCOPE.md:99,349 — The invite code format is described as "short human-readable" (e.g., WOLF-42). This format has extremely low entropy. Assuming a pattern of WORD-NN (one word from a dictionary of ~2000 common words, a dash, and a two-digit number), the total keyspace is approximately 200,000 combinations. An attacker can enumerate all valid invite codes in minutes with automated requests. Since the invite code is the ONLY mechanism preventing unauthorized users from joining a group, a brute-force attack would allow an attacker to join any group, see all movies, modify the list, and potentially disrupt the group.
Fix: (a) Increase invite code entropy: use a format like WORD-WORD-NNNN (e.g., WOLF-MESA-4829) or a random alphanumeric string of at least 8 characters, yielding a keyspace of at least 2 billion. (b) Implement strict server-side rate limiting on the invite code validation endpoint: maximum 5 attempts per IP per 10 minutes, with exponential backoff. (c) Add a lockout mechanism: after 10 failed attempts from the same IP, block for 1 hour. (d) Consider adding invite code expiry (e.g., 7 days) so old codes cannot be used indefinitely. (e) Log failed invite code attempts for monitoring.
Implementation Risk: Longer codes reduce usability (harder to share verbally). Balance security with UX by using memorable word pairs. Rate limiting by IP alone can be bypassed with rotating proxies, so consider also rate-limiting by client fingerprint or requiring a valid user session before attempting to join.
PROJECT_SCOPE.md:107-108 — User identity is persisted in browser local storage. The scope states "App reads stored user ID from browser (local storage or cookie)" to determine the logged-in user. Local storage is accessible to any JavaScript running on the same origin, making it vulnerable to XSS-based theft. If an attacker can inject any script (via a stored XSS in display names, a compromised dependency, or a browser extension), they can exfiltrate the user ID and impersonate that user from any device. Furthermore, since the user ID is a UUID with no accompanying authentication secret, anyone who knows or guesses a user's UUID can impersonate them by simply writing it to their own local storage.
Fix: (a) Do not rely solely on a user ID in local storage for authentication. Instead, issue a signed session token (JWT or opaque token) upon account creation that includes the user ID and is validated server-side on every request. Store this token in an httpOnly, Secure, SameSite=Strict cookie — not local storage. (b) Use Supabase Auth's anonymous sign-in feature, which provides proper JWT-based sessions with refresh tokens. (c) If local storage must be used for offline display purposes, ensure all server-side operations validate the session token, not just the user ID.
Implementation Risk: Moving from local storage to httpOnly cookies changes the client-side auth flow. Supabase's JS client stores tokens in local storage by default; you may need to configure a custom storage adapter or use server-side session management. Offline mode will need a fallback strategy.
PROJECT_SCOPE.md:34,271,345-346 — The scope mentions "a longer alphanumeric code shown once" and "recovery_code (text, hashed)" but does not specify: (a) the length or entropy of the recovery code, (b) the hashing algorithm used, (c) whether a salt is used, (d) rate limiting on the claim endpoint, or (e) single-use enforcement. A weak recovery code (e.g., 8 alphanumeric characters = ~41 bits of entropy) could be brute-forced. Using a fast hash like SHA-256 without a salt makes rainbow table attacks feasible. Without rate limiting on the claim endpoint, an attacker can attempt thousands of recovery codes per second to hijack accounts.
Fix: (a) Generate recovery codes with at least 128 bits of entropy (e.g., 22 alphanumeric characters or 6 random words from a wordlist). (b) Hash recovery codes with bcrypt (cost factor 12+) or Argon2id — these are computationally expensive and include built-in salting, making brute-force impractical. (c) Rate-limit the recovery code claim endpoint: maximum 3 attempts per IP per 15 minutes. (d) After a successful claim, invalidate the old recovery code and optionally issue a new one. (e) Log all claim attempts (successful and failed) for security monitoring. (f) Consider adding a secondary verification step (e.g., requiring the user to enter their display name along with the recovery code).
Implementation Risk: bcrypt/Argon2id are slow by design, which is the point. Ensure the claim endpoint handles this latency gracefully (show a loading state). Longer recovery codes are harder for users to write down — provide a copy-to-clipboard button and consider a QR code or downloadable text file.
PROJECT_SCOPE.md:48,365 — The scope calls for Supabase real-time subscriptions on the movies table for live updates. Supabase Realtime respects RLS policies IF they are configured, but the scope does not mention RLS (Finding 1). Without RLS, any client can subscribe to changes on ANY group's movies table and receive real-time updates for groups they do not belong to. Even with RLS, Supabase Realtime requires careful channel-level authorization. If the subscription is set up as a broadcast or presence channel without proper filtering, users could eavesdrop on other groups' activity.
Fix: (a) Implement RLS policies (Finding 1) — Supabase Realtime filters events based on RLS automatically for Postgres Changes subscriptions. (b) Subscribe to changes with a filter matching the user's group_id, e.g., .on('postgres_changes', { event: '*', schema: 'public', table: 'movies', filter: 'group_id=eq.[group_id]' }). (c) Verify server-side that the subscribing user is a member of the group they are subscribing to. (d) Do not use Broadcast or Presence channels for sensitive data unless you implement custom authorization via Supabase Edge Functions.
Implementation Risk: RLS-filtered real-time subscriptions may have slightly higher latency. Test that real-time updates still arrive promptly (within the 3-second target) after RLS is enabled.
PROJECT_SCOPE.md:329,396 — The TOTP secret is stored as a plain-text environment variable (MASTER_ADMIN_TOTP_SECRET). While this is standard for TOTP secrets, the design has several gaps: (a) There is no mechanism to rotate the TOTP secret without redeploying the application. (b) If the environment variable is leaked (e.g., through a Docker image layer, a CI/CD log, or a .env file committed to version control), the attacker has permanent access until the secret is changed. (c) The admin session management is described only as "secure server-side token store" with no detail on session expiry, invalidation, or binding.
Fix: (a) Set a short session TTL for admin sessions (e.g., 30 minutes) with no refresh — require re-authentication with TOTP. (b) Bind admin sessions to the originating IP address and User-Agent. (c) Implement a "logout all admin sessions" capability. (d) Store the TOTP secret using Docker secrets or a secrets manager (e.g., HashiCorp Vault, AWS Secrets Manager) rather than plain environment variables where possible. (e) Log all admin authentication attempts and actions with timestamps and IP addresses. (f) Consider adding a password alongside the TOTP code for defense-in-depth (the scope explicitly says "no password-only fallback" but a password + TOTP combination is stronger than username + TOTP alone).
Implementation Risk: Short session TTLs may frustrate the admin if they need to perform multiple actions. A 30-minute window with activity-based extension (reset TTL on each action) is a reasonable compromise. IP binding can cause issues if the admin's IP changes mid-session (e.g., mobile network switching).
PROJECT_SCOPE.md:259,326 — The scope lists TMDB_API_KEY as an environment variable and describes client-side TMDB search with debounce. If the TMDB API key is used in client-side fetch calls (e.g., from a React component), it will be visible in browser network requests and the JavaScript bundle. While TMDB API keys are free-tier and the data is public, a leaked key can be abused for: (a) excessive API calls that exhaust your rate limit and get your key revoked, (b) associating malicious usage with your account. The landing page makes unauthenticated TMDB calls, which further suggests client-side usage.
Fix: (a) Proxy all TMDB API calls through Next.js API routes or Server Actions. The client sends search queries to your server, which forwards them to TMDB with the API key. This keeps the key server-side only. (b) Implement server-side caching (e.g., in-memory or Redis) for TMDB search results to reduce API calls and improve response times. (c) Rate-limit the proxy endpoint to prevent abuse (e.g., 30 requests per user per minute). (d) Use Next.js NEXT_PUBLIC_ prefix convention correctly — do NOT prefix the TMDB key with NEXT_PUBLIC_.
Implementation Risk: Proxying adds a network hop and slight latency. Server-side caching mitigates this. Ensure the proxy endpoint does not become an open relay — validate and sanitize the search query parameter.
PROJECT_SCOPE.md:95,268 — Users enter a free-text display name during onboarding. The scope does not mention any validation, sanitization, or length limits. Display names are rendered throughout the app (movie cards, member lists, "Added by" attribution, admin panel search). Without server-side validation: (a) Stored XSS: a display name containing <script>alert(1)</script> or event handlers could execute in other users' browsers. (b) Excessively long names could break layouts or be used for denial of service. (c) Empty or whitespace-only names could cause display issues.
Fix: (a) Enforce server-side validation: minimum 1 character, maximum 30 characters, alphanumeric plus spaces and basic punctuation only (regex: ^[a-zA-Z0-9 _\-'.]{1,30}$). (b) HTML-encode all display names before rendering — use React's default JSX escaping (which escapes by default) and never use dangerouslySetInnerHTML with user data. (c) Strip or reject control characters and zero-width characters. (d) Apply the same validation in Supabase via a CHECK constraint on the display_name column.
Implementation Risk: Restrictive character sets may exclude users with non-Latin names. Consider supporting Unicode letter categories while still blocking HTML/script characters. React's JSX auto-escaping provides strong XSS protection by default, but this only works if developers consistently avoid dangerouslySetInnerHTML.
PROJECT_SCOPE.md — The scope describes numerous state-changing operations (create group, join group, add/remove movies, mark watched, delete list, admin actions) but does not mention CSRF protection. If sessions are implemented via cookies (as recommended in Finding 4), every state-changing request is vulnerable to cross-site request forgery unless CSRF tokens are used. An attacker could craft a page that, when visited by a logged-in user, automatically adds movies to their list, deletes groups, or performs admin actions.
Fix: (a) If using Next.js Server Actions (App Router), CSRF protection is built in — Server Actions check the Origin header automatically. Use Server Actions for all mutations. (b) If using API routes instead, implement CSRF tokens (double-submit cookie pattern or synchronizer token). (c) Set SameSite=Strict on session cookies. (d) Validate the Origin and Referer headers on all state-changing API routes.
Implementation Risk: SameSite=Strict cookies are not sent on cross-origin navigations, which can break login flows from external links. SameSite=Lax is a reasonable compromise that still protects against POST-based CSRF.
PROJECT_SCOPE.md:69-90 — The landing page allows unauthenticated users to roll dice and search genres against TMDB without any login. These public endpoints can be abused for: (a) TMDB API quota exhaustion via automated requests, (b) server resource consumption, (c) scraping movie data. The scope mentions client-side debounce (~300ms) but this is trivially bypassed.
Fix: (a) Implement server-side rate limiting on all public-facing endpoints (landing page rolls, genre rolls, search): 20 requests per IP per minute. (b) Use a rate-limiting middleware (e.g., express-rate-limit equivalent for Next.js, or Supabase Edge Function rate limits, or Docker-level reverse proxy rate limiting). (c) For Docker deployments, configure rate limiting at the reverse proxy level (nginx/Traefik) as the first line of defense. (d) Consider CAPTCHA or proof-of-work challenges if abuse is detected.
Implementation Risk: Aggressive rate limiting can block legitimate users on shared networks (NAT, corporate proxies). Use per-IP limits with reasonable thresholds and provide clear error messages when limits are hit.
Deployment context — The scope mentions Vercel deployment but the actual deployment target is Docker. Docker deployments introduce specific security concerns not addressed in the scope: (a) Running as root inside the container, (b) Exposing unnecessary ports, (c) Secrets in Docker image layers (environment variables baked into the image), (d) No health check endpoint defined, (e) No network isolation between services, (f) Base image vulnerabilities.
Fix: In the Dockerfile: (a) Use a minimal base image (e.g., node:20-alpine). (b) Create and switch to a non-root user: RUN addgroup -S app && adduser -S app -G app then USER app. (c) Use multi-stage builds to exclude build tools and source maps from the final image. (d) Never bake secrets into the image — use Docker secrets, a .env file mounted at runtime, or environment variables passed via docker run -e. (e) Expose only the necessary port (e.g., 3000). (f) Add a HEALTHCHECK instruction. (g) Use docker-compose with a dedicated network for the app, and do not expose the database port to the host if running Supabase locally. (h) Scan the image for vulnerabilities with docker scout or trivy before deployment.
Implementation Risk: Non-root users may lack permissions for certain operations (e.g., binding to port 80). Use a port above 1024 (like 3000) and reverse-proxy with nginx/Traefik. Alpine-based images may have compatibility issues with some npm packages that require native compilation.
PROJECT_SCOPE.md — The scope does not mention any HTTP security headers. Without these, the application is exposed to: (a) Clickjacking (no X-Frame-Options or frame-ancestors CSP directive), (b) XSS exploitation (no Content-Security-Policy), (c) MIME type sniffing attacks (no X-Content-Type-Options), (d) Information leakage (no Referrer-Policy).
Fix: Configure the following headers in Next.js next.config.js (or via reverse proxy for Docker):
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https://image.tmdb.org; connect-src 'self' https://*.supabase.co wss://*.supabase.co; frame-ancestors 'none';
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
Adjust CSP directives as needed for inline styles (Tailwind may require unsafe-inline for styles) and any third-party resources.
Implementation Risk: An overly strict CSP can break the application. Tailwind CSS may require style-src 'unsafe-inline'. TMDB poster images need img-src https://image.tmdb.org. Supabase real-time needs connect-src wss://*.supabase.co. Test thoroughly after applying headers. Use Content-Security-Policy-Report-Only during development to catch violations without blocking.
PROJECT_SCOPE.md — The scope does not mention CORS configuration. For a Docker deployment serving both the frontend and API from the same origin, CORS may not be strictly necessary. However, the Supabase client makes cross-origin requests to the Supabase hosted service, and if any API routes are added, CORS misconfiguration could allow unauthorized cross-origin access. Additionally, a permissive CORS policy (e.g., Access-Control-Allow-Origin: *) on API routes would allow any website to make authenticated requests to the application.
Fix: (a) Configure CORS on any API routes to allow only the application's own origin. (b) Do not set Access-Control-Allow-Origin: * on any endpoint that processes cookies or authentication. (c) Supabase handles its own CORS; ensure the Supabase project's allowed origins are configured to include only your application's domain. (d) For Docker, if using a reverse proxy, configure CORS at the proxy level.
Implementation Risk: CORS misconfigurations can be hard to debug. Test cross-origin requests from the browser console during development.
PROJECT_SCOPE.md:99,349 — Group names are user-provided text with no validation rules mentioned. Similar to display names (Finding 9), group names are rendered across the application and could be vectors for stored XSS or layout disruption if not validated.
Fix: Apply the same validation approach as display names: length limits (1-50 characters), character restrictions, server-side enforcement via Supabase CHECK constraints. React's JSX escaping provides baseline XSS protection.
Implementation Risk: Minimal. Standard input validation.
PROJECT_SCOPE.md — The scope describes user deletion by the Master Admin but does not mention self-service account deletion. Under GDPR and similar regulations, users have the right to delete their accounts and associated data. Even though the app collects minimal data (display name and avatar color only), the absence of a self-service deletion flow could be a compliance gap.
Fix: (a) Add a "Delete my account" option in user settings. (b) Define what happens to a user's data on deletion: movies they added should either be reassigned or have the added_by reference set to null. (c) Group memberships should be cleaned up; if the user is a List Admin, trigger the ownership transfer flow. (d) Recovery codes should be deleted. (e) Document the data retention policy.
Implementation Risk: Cascading deletions can have unintended side effects. Use soft deletes initially (mark as deleted, purge after 30 days) to allow recovery from accidental deletions.
PROJECT_SCOPE.md:296,366 — Trailer URLs are fetched from TMDB and stored in the database. When a user clicks "Trailer," the URL opens in a new tab. If the stored URL is somehow corrupted or manipulated (e.g., via a TMDB API response manipulation, a database injection, or an admin error), the user could be redirected to a malicious site.
Fix: (a) Validate that stored trailer URLs match expected YouTube/TMDB URL patterns before storing them (e.g., must start with https://www.youtube.com/watch?v= or https://www.themoviedb.org/). (b) When rendering the trailer link, add rel="noopener noreferrer" to prevent the opened page from accessing the opener window. (c) Consider proxying trailer URLs through an interstitial "You are leaving MovieDice" page.
Implementation Risk: TMDB may change their trailer URL format. Use a flexible allowlist (domain-based rather than full URL pattern) and update as needed.
PROJECT_SCOPE.md — The scope does not mention application-level logging for security events. Without logging, it is impossible to detect or investigate: brute-force attempts on invite codes or recovery codes, unauthorized admin access, mass deletion of data, or API abuse.
Fix: Implement structured logging for: (a) All authentication events (user creation, recovery code claims, admin login attempts). (b) All authorization failures (accessing groups the user does not belong to, admin route access without session). (c) Rate limit violations. (d) Admin actions (list/user deletions). (e) Use structured JSON logging and forward to a log aggregation service (e.g., Sentry, Datadog, or even Docker logging drivers).
Implementation Risk: Excessive logging can create performance overhead and storage costs. Log at appropriate levels (INFO for auth events, WARN for failures, ERROR for system issues). Ensure logs do not contain sensitive data (recovery codes, TOTP codes).
PROJECT_SCOPE.md:95-104 — The scope describes a custom anonymous auth implementation (generate UUID, store in local storage). Supabase provides a built-in Anonymous Sign-In feature that handles session management, JWT issuance, refresh tokens, and RLS integration automatically. The custom approach requires implementing all of these security primitives from scratch, increasing the risk of implementation errors.
Fix: Evaluate using Supabase's built-in anonymous auth (supabase.auth.signInAnonymously()). This provides: automatic JWT session management, secure token storage, RLS integration via auth.uid(), and a migration path to linking the anonymous account with email/password later if needed. The recovery code mechanism could still be layered on top for device transfer.
Implementation Risk: Supabase anonymous auth may not perfectly match the desired UX flow. Test whether the anonymous sign-in flow integrates cleanly with the display name and recovery code features. The anonymous user could be linked to a full account later, but this adds complexity beyond MVP scope.
is_master_admin Boolean on Users Table Is Unnecessary and RiskyPROJECT_SCOPE.md:272 — The data model includes is_master_admin (boolean, default false) on the users table. Since the master admin is configured via environment variables and has a separate session, storing an admin flag on a regular user row is redundant. Worse, if RLS policies are not correctly configured, an attacker could potentially set is_master_admin = true on their own user row and gain admin privileges (depending on how the application checks admin status).
Fix: Remove is_master_admin from the users table entirely. Admin status should be determined solely by a valid admin session token issued after TOTP verification. The admin session is already described as separate from regular user sessions — there is no need for a database flag. If admin status must be checked in database queries, use a separate admins table accessible only via the service role key.
Implementation Risk: If any application logic already references is_master_admin, it must be refactored to check the admin session instead. This is a design-phase change, so the risk is minimal.
PROJECT_SCOPE.md:401-402 — The scope plans for PWA support with a service worker for offline capability. Service workers can cache sensitive data (user sessions, group data, movie lists) in the browser's Cache API, which persists even after the tab is closed. On shared devices, this cached data could be accessed by subsequent users.
Fix: (a) Do not cache authentication tokens or session data in the service worker cache. (b) Cache only static assets (images, CSS, JS) and public data (TMDB posters). (c) Implement cache expiry and versioning. (d) On logout or account deletion, clear all service worker caches (caches.delete()). (e) Use the Clear-Site-Data header on logout to clear caches, cookies, and storage in one operation.
Implementation Risk: Aggressive cache clearing can degrade the offline experience. Balance security with usability — cache static assets aggressively but treat user-specific data as ephemeral.
PROJECT_SCOPE.md:51,353 — The scope states invite code regeneration "revokes access for anyone with the old code." This is misleading — regenerating the code only prevents new joins with the old code. Existing members who joined with the old code remain in the group. There is no mechanism to forcibly remove members who joined with a compromised code, other than the admin manually removing them one by one.
Fix: This is a design clarification, not a vulnerability. Document clearly that code regeneration prevents new joins but does not remove existing members. Consider adding a "Remove all non-admin members" bulk action for scenarios where a code was widely compromised.
Implementation Risk: Minimal. This is primarily a documentation and UX expectation issue.
| Severity | Total |
|---|---|
| Critical | 2 |
| High | 5 |
| Medium | 6 |
| Low | 4 |
| Info | 4 |
This is a pre-implementation architecture review. No source code exists.
| # | File | Type |
|---|---|---|
| 1 | PROJECT_SCOPE.md |
Architecture/design specification |
| 2 | research/PROJECT_INFO.md |
Project context file |
Reviewed: 2026-04-05 | Auditor: Claude (automated) | Type: Second-pass review of updated PROJECT_SCOPE.md
Context: The scope has been significantly updated since the first review. RLS policies, Supabase Anonymous Sign-In, Argon2id hashing, WORD-WORD invite codes with rate limiting, iron-session for admin, security headers, Docker hardening, and input validation have all been added. This section covers only NEW security gaps in the updated architecture. Findings from the first review that are now addressed in the scope are not repeated.
Note on research: Web search was unavailable during this review. All analysis is based on the reviewer's existing knowledge of these technologies. Findings are cached in research/SECURITY-RESEARCH.md for future reference.
PROJECT_SCOPE.md:391 -- The scope calls for a "self-hosted Supabase Docker stack" using the official docker-compose configuration, but does not mention replacing the default secrets. Supabase's official self-hosted .env file ships with well-known placeholder values for JWT_SECRET, POSTGRES_PASSWORD, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_USERNAME, and DASHBOARD_PASSWORD. These defaults are published on GitHub and known to every attacker. If any of these are left at their default values: (a) the JWT_SECRET is public, meaning anyone can forge valid JWTs and impersonate any user or the service role, completely bypassing RLS; (b) the POSTGRES_PASSWORD allows direct database access; (c) the SERVICE_ROLE_KEY (derived from JWT_SECRET) bypasses all RLS policies. This is the single most critical risk for the self-hosted deployment model.
Fix: Add an explicit checklist to the deployment documentation and Phase 1.7 requiring replacement of ALL Supabase default secrets before first deployment:
JWT_SECRET (at least 32 random characters, recommend 64)ANON_KEY and SERVICE_ROLE_KEY using the new JWT_SECRET via Supabase's JWT generation tool or jsonwebtoken libraryPOSTGRES_PASSWORD (32+ random characters)DASHBOARD_USERNAME and DASHBOARD_PASSWORD for StudioGOTRUE_JWT_SECRET and PGRST_JWT_SECRET match the new JWT_SECRETImplementation Risk: Regenerating the JWT secret requires regenerating both the anon key and service role key, since these are JWTs signed with that secret. All three values must be updated in lockstep. If they are mismatched, authentication will silently fail.
PROJECT_SCOPE.md:391 -- The self-hosted Supabase stack includes Supabase Studio, which is a full administrative dashboard providing direct access to the database, table editor, SQL editor, auth user management, and configuration. By default, Studio is exposed on port 3000 in the docker-compose configuration. If the Caddy reverse proxy or Docker port mapping makes Studio accessible from the internet, any attacker who discovers it (or any scanner hitting common ports) can attempt to log in. Studio's default credentials are supabase/this_password_is_insecure_and_should_be_updated (or similar well-known defaults). Even with changed credentials, exposing a full database admin panel to the internet is unnecessary attack surface.
Fix: (a) Do NOT expose Studio's port to the host network in docker-compose. Remove or comment out the ports: mapping for the Studio service, or bind it to 127.0.0.1:3000 only. (b) Access Studio only via SSH tunnel when needed: ssh -L 3000:localhost:3000 your-server. (c) If Studio must be exposed, place it behind Caddy with HTTP basic auth and restrict to a non-guessable path. (d) Consider removing the Studio service entirely from the production docker-compose and only running it in development. (e) Add this to the Phase 1.7 deployment checklist.
Implementation Risk: Removing Studio from production means database administration must be done via SSH + psql or a tunneled connection. This is acceptable for a single-admin project and significantly reduces attack surface.
PROJECT_SCOPE.md:97-98,270 -- The updated scope correctly adopts Supabase Anonymous Sign-In, which issues JWTs managed by GoTrue. However, the Supabase JavaScript client (@supabase/supabase-js) stores both access tokens and refresh tokens in localStorage by default. This is inherent to how the Supabase client works and cannot be changed to httpOnly cookies without server-side session management, because the JavaScript client needs to read the token to attach it to requests. If an XSS vulnerability exists anywhere in the application (a compromised npm dependency, a CSP bypass, a stored XSS in a field not covered by React's auto-escaping), an attacker can exfiltrate both the access token and the refresh token from localStorage, gaining full session hijack capability. The refresh token is especially dangerous because it has a long lifetime (typically 1 week by default in GoTrue).
This is a known Supabase design tradeoff, but it should be explicitly acknowledged and mitigated in the scope.
Fix: (a) Use @supabase/ssr with the Next.js App Router to store tokens in cookies instead of localStorage. While these cookies are not httpOnly (the JS client must read them), this still provides some benefit: cookies can be set with SameSite=Lax and Secure flags, and they are not accessible to injected scripts that use localStorage APIs without also reading document.cookie. (b) Reduce refresh token lifetime in GoTrue configuration to the minimum acceptable (e.g., 24 hours instead of 7 days). (c) Prioritize a strict CSP to prevent XSS as the primary mitigation. (d) Consider implementing Content-Security-Policy nonces for inline scripts if any are needed. (e) Ensure no use of dangerouslySetInnerHTML anywhere in the codebase.
Implementation Risk: @supabase/ssr changes the auth flow and requires middleware to handle token refresh on the server side. This adds complexity but is the recommended approach for Next.js App Router projects. Test that real-time subscriptions still authenticate correctly when tokens are in cookies.
PROJECT_SCOPE.md:351-358 -- The RLS policy descriptions say "Full CRUD for members of the owning group only" for movies and "insertable via valid invite code flow" for group_members. However, INSERT policies must also verify that the user cannot spoof the added_by column in movies or the user_id column in group_members. Without a WITH CHECK (added_by = auth.uid()) clause on the movies INSERT policy, a group member could insert movies attributed to another user. Without WITH CHECK (user_id = auth.uid()) on group_members, a user could potentially insert membership records for other users.
Fix: For every INSERT policy, add explicit WITH CHECK constraints:
movies INSERT: WITH CHECK (added_by = auth.uid() AND group_id IN (SELECT group_id FROM group_members WHERE user_id = auth.uid()))group_members INSERT: WITH CHECK (user_id = auth.uid()) -- and consider whether client-side INSERT is even appropriate here, or if joining should be a server-side operation via the service role keymovies UPDATE: Ensure users cannot change added_by to another user's IDgroup_members: Ensure users cannot change their own role from member to adminImplementation Risk: Overly restrictive policies can break legitimate operations. Test that the List Admin can still perform admin actions (member removal, etc.) -- these may need to go through server-side API routes using the service role key rather than direct client-side queries.
PROJECT_SCOPE.md:391 -- The self-hosted Supabase stack uses Kong as the API gateway, which exposes PostgREST, GoTrue, Realtime, and Storage endpoints. By default, Kong listens on port 8000 (HTTP) and 8443 (HTTPS). In the architecture, the Next.js application communicates with Supabase through Kong. If Kong's ports are also exposed to the internet directly (via Docker port mapping), attackers can bypass the Next.js application entirely and interact with the Supabase API directly -- crafting arbitrary PostgREST queries, calling GoTrue auth endpoints, and probing for misconfigurations. While RLS provides protection, direct API access exposes more attack surface than necessary (schema introspection, auth endpoint abuse, etc.).
Fix: (a) Do NOT expose Kong ports (8000, 8443) to the host network. Bind them to 127.0.0.1 only, or better, keep them internal to the Docker network with no port mapping. (b) The Next.js container communicates with Kong over the internal Docker network (e.g., http://kong:8000). (c) Caddy should only proxy requests to the Next.js application, not to Kong. (d) If the Supabase JS client in the browser needs direct access to Supabase (for Realtime WebSocket connections), route those through Caddy to Kong on a specific path (e.g., /supabase/) rather than exposing Kong directly. (e) Disable PostgREST schema introspection in production by setting PGRST_OPENAPI_MODE=disabled.
Implementation Risk: The Supabase JS client is configured with SUPABASE_URL, which tells the browser where to connect for auth, database queries, and real-time subscriptions. If Kong is not directly accessible, SUPABASE_URL must point to a Caddy-proxied path, which requires Caddy configuration to forward Supabase API and WebSocket traffic correctly. This is a non-trivial configuration step.
PROJECT_SCOPE.md:270 -- The scope uses only Supabase Anonymous Sign-In. However, GoTrue (Supabase's auth service) enables multiple auth providers by default, including email/password signup, magic link, and potentially OAuth providers. If these are not explicitly disabled, an attacker could create a non-anonymous authenticated user via the email/password signup endpoint, which would have the authenticated role and potentially different RLS treatment than expected. Depending on RLS policy design, this could grant unintended access.
Fix: In the GoTrue configuration (docker-compose environment variables for the auth service):
GOTRUE_EXTERNAL_EMAIL_ENABLED=falseGOTRUE_EXTERNAL_PHONE_ENABLED=falseGOTRUE_EXTERNAL_*_ENABLED=false)GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=trueGOTRUE_DISABLE_SIGNUP=false (anonymous sign-in counts as signup)GOTRUE_MAILER_AUTOCONFIRM=true as a safety net so any accidentally enabled email flows don't send actual emailsImplementation Risk: If auth methods are disabled after users have been created via those methods, existing sessions remain valid until they expire. Since this is a new deployment, there is no migration risk.
PROJECT_SCOPE.md:309,374 -- The scope defines a 12-month inactivity auto-deletion policy for users, with last_active_at tracked on the users table. However, the scope does not address what happens to groups when the last member (or the only admin) is auto-deleted. Scenarios: (a) User is the sole admin of a group with other members -- deleting the admin leaves an admin-less group where no one can perform admin actions (rename, delete, regenerate invite code, remove members). (b) User is the last member of a group -- the group and its movies become orphaned data with no one who can access or delete them. (c) If added_by has a foreign key to users.id without ON DELETE handling, deletion will either fail (FK violation) or cascade-delete all movies the user added.
Fix: The auto-deletion job must handle these cases:
added_by foreign key to ON DELETE SET NULL so movies are preserved but the attribution is anonymized.last_active_at timestamp must be updated on meaningful activity (not just login). Relevant activity: adding/removing movies, marking watched, rolling dice, joining/creating groups. Use Supabase's auth.users.last_sign_in_at as one input but supplement it with application-level activity tracking.Implementation Risk: The admin transfer logic adds complexity to the deletion job. If the job fails partway through (deletes some users but crashes before handling their groups), the database could be left in an inconsistent state. Wrap the entire per-user deletion in a transaction.
PROJECT_SCOPE.md:35,315 -- The scope specifies WORD-WORD format (e.g., WOLF-MOON) with rate limiting on the join endpoint (5-10 failed attempts per IP per window). The rate limiting makes brute force impractical (see research cache), but the scope does not specify: (a) the word list source or size, (b) whether words are case-sensitive, (c) maximum word length for usability, or (d) filtering for offensive or confusing words. These implementation details affect both security and usability.
Fix: (a) Use a curated word list of at least 2,000 words (yielding 4 million combinations with WORD-WORD; with rate limiting at 5/15min, brute force would take ~24 years). A list of 4,000+ words is even better. (b) Use uppercase only for display; comparison should be case-insensitive. (c) Filter the word list to remove: offensive/vulgar words, words shorter than 3 characters, words longer than 8 characters (for verbal sharing), easily confused word pairs (e.g., "THEIR"/"THERE"), and culturally sensitive terms. (d) When generating a code, verify the combination is not already in use (collision check). (e) Consider using a well-known word list as a base, such as the EFF short word list (1,296 words) or a filtered subset of the BIP39 word list (2,048 words). (f) Document the word list in the codebase with a rationale for its size.
Implementation Risk: Smaller word lists are easier to share verbally but have higher collision probability and lower brute-force resistance. With rate limiting in place, a 2,000-word list provides adequate security. If multiple groups exist simultaneously, the collision check becomes important -- with 4M possible codes and hundreds of active groups, collisions are rare but should be handled.
PROJECT_SCOPE.md:34,308,368 -- The scope specifies Argon2id for recovery code hashing but does not define the memory, time (iterations), or parallelism parameters. In a Docker container with limited memory (typical: 512MB-1GB), using Argon2id's high-memory settings (e.g., 64 MiB per hash) could cause OOM kills if multiple recovery code operations run concurrently. Conversely, using parameters that are too low undermines the purpose of Argon2id.
Fix: Specify parameters in the scope or implementation docs:
Implementation Risk: If parameters are not specified, developers may use library defaults which vary: the argon2 npm package defaults to memory=65536 KiB (64 MiB), which could be problematic in constrained containers. Always pass explicit parameters rather than relying on defaults.
PROJECT_SCOPE.md:278,392 -- Caddy is used as the reverse proxy for HTTPS termination. Caddy automatically obtains TLS certificates via Let's Encrypt and renews them. However, if Caddy's data directory (/data) is not mounted as a persistent Docker volume, certificates are lost on every container restart. This causes Caddy to request new certificates each time, and Let's Encrypt enforces strict rate limits: 5 duplicate certificates per registered domain per week. After hitting this limit, the site will fail to obtain a certificate and HTTPS will stop working for up to a week.
Fix: In docker-compose.yml, mount persistent volumes for Caddy:
volumes:
- caddy_data:/data
- caddy_config:/config
And declare the named volumes. This ensures certificates survive container restarts, upgrades, and redeployments. Additionally: (a) Test certificate renewal in a staging environment using Let's Encrypt's staging ACME endpoint first (acme_ca https://acme-staging-v02.api.letsencrypt.org/directory in Caddyfile). (b) Ensure ports 80 and 443 are accessible from the internet for ACME HTTP-01 challenges. (c) Do NOT use on_demand TLS for a single-domain deployment.
Implementation Risk: If volumes are accidentally deleted during a cleanup, certificates are lost. Back up the caddy_data volume periodically, though Caddy will re-obtain certificates automatically (subject to rate limits).
PROJECT_SCOPE.md:391 -- The self-hosted Supabase docker-compose typically maps Postgres port 5432 to the host for local development convenience. In production, exposing the Postgres port to the host network (and potentially the internet, depending on firewall configuration) allows direct database connections that bypass all application-level security, RLS policies (since a direct connection uses the postgres role, not the anon role), and authentication.
Fix: (a) Remove the ports: mapping for the Postgres service in the production docker-compose, or bind it to 127.0.0.1:5432 only. (b) All services that need database access (PostgREST, GoTrue, Realtime, pg_cron) communicate over the internal Docker network. (c) For database administration, use SSH tunneling: ssh -L 5432:localhost:5432 your-server then connect via psql. (d) Ensure the POSTGRES_PASSWORD is strong (32+ random characters) as a defense-in-depth measure even if the port is not exposed.
Implementation Risk: Removing the port mapping means tools like pgAdmin or Supabase Studio cannot connect from outside the Docker network without an SSH tunnel. This is an acceptable operational tradeoff for production security.
PROJECT_SCOPE.md:53,402 -- The scope specifies a 32+ character IRON_SESSION_SECRET for encrypting admin session cookies. iron-session supports secret rotation by passing an array of secrets (newest first), which allows changing the secret without immediately invalidating all active sessions. However, the scope does not mention a rotation procedure. If the secret is compromised (e.g., via a leaked .env file or Docker image layer), all admin sessions encrypted with that secret can be forged. Without a rotation procedure, the response time to a compromise is slower.
Fix: (a) Document the secret rotation procedure: generate a new secret, add it as the first element of an array with the old secret as the second, deploy, then remove the old secret after 8 hours (the session expiry). (b) Use a secret of at least 64 characters for additional entropy margin. (c) Ensure the secret is never committed to version control or baked into Docker image layers.
Implementation Risk: Minimal. iron-session's array-based rotation is straightforward. The main risk is forgetting to remove the old secret after the rotation window.
PROJECT_SCOPE.md:379-386 -- The security headers section mentions restricting connect-src to "Supabase wss://" but uses the wildcard *.supabase.co, which is the pattern for Supabase's managed hosting. For a self-hosted deployment, the Supabase services are reached via the Caddy reverse proxy on the project's own domain (or a subdomain). The CSP directives must reflect the actual self-hosted URLs, not the managed hosting pattern.
Fix: Update the CSP template to use the actual deployment domain:
connect-src 'self' wss://yourdomain.com (for Supabase Realtime via Caddy proxy)img-src 'self' https://image.tmdb.org (unchanged)*.supabase.co references since traffic goes through the same origin or a known subdomainconnect-src 'self' may be sufficient for API calls, with only the WebSocket needing an explicit entryscript-src 'self' -- verify no inline scripts are needed; if Next.js injects inline scripts, use noncesImplementation Risk: An incorrect CSP will break the application in production. Use Content-Security-Policy-Report-Only during development (already in the scope) and test all functionality including Realtime WebSocket connections before switching to enforcement mode.
PROJECT_SCOPE.md:277,394 -- The scope uses pg_cron for background jobs (landing reel refresh, trailer URL refresh, and potentially inactive account deletion). In self-hosted Supabase, pg_cron jobs run as the postgres superuser by default, which bypasses all RLS policies. If a pg_cron job is configured to call an Edge Function via net.http_post, the Edge Function receives no auth context. If the Edge Function uses the service role key to perform database operations, this is expected and acceptable. However, if pg_cron SQL jobs directly modify tables, they do so with superuser privileges, which means a SQL injection in the job definition (unlikely but possible if job SQL is dynamically constructed) would have unrestricted access.
Fix: (a) Keep pg_cron job SQL static and simple -- use it only to trigger Edge Functions via HTTP, not to perform complex data operations directly. (b) Edge Functions should use the service role key with explicit authorization checks. (c) If pg_cron must run SQL directly (e.g., for the inactive account deletion), review the SQL carefully for injection risks and ensure it is parameterized. (d) Monitor pg_cron job execution logs for failures or unexpected behavior.
Implementation Risk: Minimal for this project since the pg_cron jobs are straightforward (refresh posters, refresh trailer URLs, delete inactive accounts). The risk increases if job definitions become more complex over time.
PROJECT_SCOPE.md:270 -- Supabase's anonymous sign-in creates users with is_anonymous: true in the JWT. Supabase supports "linking" anonymous users to a permanent identity (email/password) via updateUser(). While the scope explicitly excludes email-based auth, the linking capability remains available in GoTrue unless disabled. An anonymous user who discovers this could call supabase.auth.updateUser({ email: '...' }) from the browser console, potentially converting their anonymous account to a permanent one with email confirmation, which could conflict with the privacy-first design intent.
Fix: This is a design decision, not a vulnerability. If account linking should not be possible: (a) add RLS or server-side middleware that rejects updateUser calls that add email/password to anonymous users, or (b) disable email auth entirely in GoTrue (see Finding 28, which covers this). If account linking is desirable for a future upgrade path, document it as intentional.
Implementation Risk: Disabling email auth entirely (Finding 28) addresses this comprehensively. No additional action needed if that fix is implemented.
last_active_at Update Strategy Needs DefinitionPROJECT_SCOPE.md:309 -- The last_active_at column is described as "updated on login/activity" but the update triggers are not specified. If last_active_at is only updated on explicit login (session creation), a user who remains active for months via token refresh without ever re-authenticating would appear inactive and could be auto-deleted while actively using the application. This is especially relevant with Supabase's automatic token refresh, which extends sessions without triggering a new "login."
Fix: Define specific events that update last_active_at: (a) session creation (initial signInAnonymously()), (b) token refresh (via a Supabase onAuthStateChange listener on the server side), (c) any write operation (add/remove movie, mark watched, create/join group). Implement via: a Postgres trigger on INSERT/UPDATE to movies and group_members tables that updates users.last_active_at, plus server-side middleware that updates it on authenticated API route calls. Avoid updating on every read (too noisy) but ensure at least weekly updates for active users.
Implementation Risk: Frequent updates to last_active_at create write amplification on the users table. Use a throttled approach: only update if the current value is more than 24 hours old (e.g., UPDATE users SET last_active_at = now() WHERE id = auth.uid() AND last_active_at < now() - interval '1 day').
| Severity | New Findings |
|---|---|
| Critical | 2 (23, 24) |
| High | 4 (25-28) |
| Medium | 5 (29-33) |
| Low | 3 (34-36) |
| Info | 2 (37, 38) |
WITH CHECK clauses on INSERT policies create authorization bypass vectors. (Findings 26, 28)auth.uid()NEXT_PUBLIC_ exclusionis_master_admin has been removed from the users table as recommended| # | File | Type |
|---|---|---|
| 1 | PROJECT_SCOPE.md (updated) |
Architecture/design specification |
| 2 | research/SECFILE.md (first review) |
Previous security audit |
| 3 | research/PROJECT_INFO.md |
Project context file |
| 4 | research/SECURITY-RESEARCH.md (created) |
Security research cache |