auth.uid() and app_metadata.user_type.
Core Principles
- Authenticated-only access — Anonymous users have no access to ads, saved data, or feeds
- User isolation — Users can only read/write their own saved ads, projects, and project links
- Service role for admin operations — Admin endpoints use the Supabase service role key to bypass RLS
- Admin-only tables — Fanbasis integration tables and webhook logs are restricted to admin users
apify_ads_raw
RLS Enabled: YesPolicies
Authenticated can read ads raw
Operation:
Role:
Policy:
SELECTRole:
authenticatedPolicy:
using (true)All authenticated users can read all ads. No user-scoping on the raw table.Revocations
The
ads_feed_v2 view uses security_invoker = true, so this policy applies when querying the view as well.user_saved_ads
RLS Enabled: YesPolicies
Users can read own saved ads
Operation:
Role:
Policy:
SELECTRole:
authenticatedPolicy:
using (auth.uid() = user_id)Users can only see their own saved ads.Users can create own saved ads
Operation:
Role:
Policy:
INSERTRole:
authenticatedPolicy:
with check (auth.uid() = user_id)Users can only insert rows where user_id = auth.uid().Users can update own saved ads
Operation:
Role:
Policy:
UPDATERole:
authenticatedPolicy:
using (auth.uid() = user_id) and with check (auth.uid() = user_id)Users can only update their own saved ads and cannot change user_id to another user.Users can delete own saved ads
Operation:
Role:
Policy:
DELETERole:
authenticatedPolicy:
using (auth.uid() = user_id)Users can only delete their own saved ads.Revocations
user_projects
RLS Enabled: YesPolicies
Users can read own projects
Operation:
Role:
Policy:
SELECTRole:
authenticatedPolicy:
using (auth.uid() = user_id)Users can create own projects
Operation:
Role:
Policy:
INSERTRole:
authenticatedPolicy:
with check (auth.uid() = user_id)Users can update own projects
Operation:
Role:
Policy:
UPDATERole:
authenticatedPolicy:
using (auth.uid() = user_id) and with check (auth.uid() = user_id)Users can delete own projects
Operation:
Role:
Policy:
DELETERole:
authenticatedPolicy:
using (auth.uid() = user_id)Revocations
user_saved_ad_projects
RLS Enabled: YesPolicies
Users can read own saved ad project links
Operation:
Role:
Policy:
SELECTRole:
authenticatedPolicy:
using (auth.uid() = user_id)Users can create own saved ad project links
Operation:
Role:
Policy:
INSERTRole:
authenticatedPolicy:
with check (auth.uid() = user_id)Users can update own saved ad project links
Operation:
Role:
Policy:
UPDATERole:
authenticatedPolicy:
using (auth.uid() = user_id) and with check (auth.uid() = user_id)Users can delete own saved ad project links
Operation:
Role:
Policy:
DELETERole:
authenticatedPolicy:
using (auth.uid() = user_id)Foreign Key Enforcement
RLS policies alone don’t prevent malicious project ID injection. The table’s foreign keys enforce ownership:project_id they don’t own, the foreign key constraint fails before RLS even evaluates.
Revocations
fanbasis_enabled_offers
RLS Enabled: YesPolicies
Authenticated can read fanbasis_enabled_offers
Operation:
Role:
Policy:
SELECTRole:
authenticatedPolicy:
using (true)All authenticated users can see which offers are enabled (needed for admin UI).Admins can insert fanbasis_enabled_offers
Operation:
Role:
Policy:
INSERTRole:
authenticatedPolicy:
with check ((auth.jwt() -> 'app_metadata' ->> 'user_type') = 'admin')Admins can update fanbasis_enabled_offers
Operation:
Role:
Policy:
UPDATERole:
authenticatedPolicy:
using ((auth.jwt() -> 'app_metadata' ->> 'user_type') = 'admin')Admins can delete fanbasis_enabled_offers
Operation:
Role:
Policy:
DELETERole:
authenticatedPolicy:
using ((auth.jwt() -> 'app_metadata' ->> 'user_type') = 'admin')Revocations
fanbasis_webhook_log
RLS Enabled: YesPolicies
Admins can read fanbasis_webhook_log
Operation:
Role:
Policy:
SELECTRole:
authenticatedPolicy:
using ((auth.jwt() -> 'app_metadata' ->> 'user_type') = 'admin')Only admin users can view webhook logs.Revocations
The webhook endpoint (
/api/fanbasis/webhook) uses the service role key to insert logs, bypassing RLS.page_rip_log
RLS Enabled: YesPolicies
None. This table is service-role only. No policies defined forauthenticated or anon.
Access Pattern
The Page Ripper API (/api/download-page) uses the Supabase service role key to:
- Query recent captures for rate-limit enforcement
- Insert new capture log entries
Admin vs. Member Access
Admin Detection
AdRecon usesauth.users.raw_app_meta_data.user_type to distinguish admins:
- Migration
20260224212000_set_admin_user_type.sqlfor initial admin accounts - Admin API (
/api/admin/users) for subsequent role changes
Admin-Only Operations
Database-level:- Insert/update/delete on
fanbasis_enabled_offers - Read
fanbasis_webhook_log
GET /api/admin/users— list all usersPATCH /api/admin/users— change user rolesDELETE /api/admin/users— delete usersPOST /api/admin/users— send password reset emailsGET /api/admin/fanbasis— Fanbasis integration status, offers, logsPOST /api/admin/fanbasis— test connection, sync products, register webhook, toggle offers
Frontend Gating
The React app (src/App.tsx) checks user.user_metadata.user_type and renders:
<AdminDashboard />for admins on/adminor/app/admin<CenteredMessage>(access denied) for non-admins on admin routes<Dashboard />for all users on main routes
Service Role Operations
The service role key (SUPABASE_SERVICE_ROLE_KEY) bypasses RLS entirely. It’s used in:
-
Admin API endpoints (
/api/admin/users,/api/admin/fanbasis)- Query
auth.usersvia Supabase Auth Admin API - Query
fanbasis_enabled_offers,fanbasis_webhook_log - Update user metadata
- Query
-
Fanbasis webhook (
/api/fanbasis/webhook)- Insert into
fanbasis_webhook_log - Create/update
auth.userson payment events
- Insert into
-
Page Ripper (
/api/download-page)- Query
page_rip_logfor rate-limit enforcement - Insert new capture entries
- Query
Authenticated vs. Anonymous
| Role | Can Access Feeds | Can Save Ads | Can Create Projects | Can View Admin Pages |
|---|---|---|---|---|
anon | No | No | No | No |
authenticated (member) | Yes | Yes | Yes | No |
authenticated (admin) | Yes | Yes | Yes | Yes |
Revocations Summary
All tables revoke permissions fromanon:
Auth component in src/App.tsx and cannot query any data.
RPC Function Security
All RPC functions usesecurity invoker mode, meaning they run with the caller’s permissions and respect RLS:
set_saved_ad_projects(ad_archive_id, project_ids[])
Security:
Checks:
security invokerChecks:
- Throws if
auth.uid()is null - Validates all
project_idsbelong to the calling user via join touser_projects - Inserts into
user_saved_adsanduser_saved_ad_projectsscoped toauth.uid()
remove_saved_ad(ad_archive_id)
Security:
Checks:
security invokerChecks:
- Throws if
auth.uid()is null - Deletes only where
user_id = auth.uid()
remove_ad_from_project(ad_archive_id, project_id)
Security:
Checks:
security invokerChecks:
- Throws if
auth.uid()is null - Deletes only where
user_id = auth.uid()andproject_idmatches
get_project_counts_for_ads(ad_ids[])
Security:
Scope:
security invokerScope:
- Joins to
user_saved_ad_projectswithuser_id = auth.uid() - Returns project counts only for the calling user’s saved ads
Migration Hardening
Migration20260223091000_harden_legacy_security_objects.sql sets search_path = public on all functions to prevent search path attacks:
public schema, blocking malicious schema injection.
Security Best Practices
User isolation is enforced at multiple layers:
- RLS policies on
user_id = auth.uid() - Foreign key constraints that validate ownership
- RPC functions that throw on invalid
auth.uid()or mismatched user IDs - Frontend route guards that hide admin UI from non-admins
Testing RLS
To test RLS policies, create a test user and verify:- User A cannot see User B’s saved ads
- User A cannot insert a saved ad with
user_id = User B - User A cannot add an ad to User B’s project (foreign key constraint fails)
- Anonymous users get empty results when querying
ads_feed_v2 - Admin users can query
fanbasis_webhook_log, members cannot