Back to settings

Security & audit

How Stadium Deck protects your data: row-level security coverage, storage isolation, and upload validation rules.

Row-level security coverage

TableScopeReadInsertUpdateDeletePolicy
profilesOwner-only (auth.uid() = id)OwnerOwnerOwnersupabase/migrations/20260511184419_*.sql
user_libraryOwner-only (auth.uid() = user_id)OwnerOwnerOwnerOwnersupabase/migrations/20260511185043_*.sql
storage.objects (avatars bucket)Owner folder ((storage.foldername(name))[1] = auth.uid())OwnerOwnerOwnerOwnersupabase/migrations/20260511195325_*.sql
Every table has RLS enabled and policies scoped to the owning user. The service-role key is used only in server functions and never shipped to the browser.

Upload validation

Audio uploads (tracks)

  • Stored locally in IndexedDB; never uploaded to a public bucket.
  • MIME type must start with audio/ and be parseable by the Web Audio API.
  • File size capped by browser storage quota; oversize files rejected at decode.

Image uploads (board buttons)

  • Allowed MIME types: image/png, image/jpeg, image/webp, image/gif (SVG excluded — script/XXE risk).
  • Maximum size: 5 MB. Empty files rejected.
  • Stored in IndexedDB and served as object URLs — never written to a public bucket.

Avatar uploads (profile)

  • Allowed: PNG, JPEG, WebP, GIF · max 2 MB.
  • Bucket is private; objects keyed under `${userId}/…` and gated by storage RLS.
  • Rendered via short-lived signed URLs (createSignedUrl, 1h TTL) — no public URLs.
Image rules live in src/lib/image-validation.ts; avatar rules live in src/routes/profile.tsx.

Avatar signed-URL flow

The avatars bucket is private. Avatars are never served from a permanent public URL — every render goes through a fresh signed URL that expires after one hour.

  1. Upload writes to {auth.uid()}/avatar-…; storage RLS rejects writes whose first folder isn't the caller's user id.
  2. Profile reads call supabase.storage.from("avatars").createSignedUrl(path, 60 * 60) — a 3,600-second TTL.
  3. The signed URL is held only in component state; it is never persisted to the database, broadcast over realtime, or written into profiles.avatar_url (which stores the storage path, not a URL).
  4. When the URL expires, the next mount re-signs from the path. Stale links return 403 and cannot be re-shared.

No public URL methods are used anywhere in the codebase. A repo-wide search for getPublicUrl returns zero matches, and the bucket itself is configured with public = false.

Implementation: src/routes/profile.tsx · bucket policy: supabase/migrations/20260511195325_*.sql.

HTTP security headers

  • Content-Security-Policy

    Restricts script, style, image, and connect origins. Only self + Supabase APIs allowed for data; framing fully blocked.

    default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' blob: data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
  • X-Frame-Options

    Legacy clickjacking guard. Mirrors CSP frame-ancestors 'none'.

    DENY
  • Referrer-Policy

    Sends only the origin to cross-site requests; full URL kept for same-origin navigation.

    strict-origin-when-cross-origin
  • X-Content-Type-Options

    Disables MIME sniffing so browsers respect declared content types.

    nosniff
  • Permissions-Policy

    Denies access to camera, microphone, geolocation, and payment APIs.

    camera=(), microphone=(), geolocation=(), payment=()
Defined in src/lib/security-headers.ts and applied to every response by the request middleware in src/start.ts.

Security event log

0 events · last 100 kept on this device

No events recorded.

Auth, storage, and upload validation failures will appear here with timestamps.

Operating practices

  • Sync failures show a generic banner; raw errors stay in the console only.
  • Auth uses email/password and Google OAuth; no anonymous sign-ups.
  • Protected routes redirect to /login with a return-path banner; signed-in users on /login bounce home.
  • Locally-stored audio and images live in IndexedDB on this device — clearing browser data wipes them. Use settings → backup to export.