Skip to content

Supabase RLS and Storage Implementation Guide

Status: ✅ Fully Implemented (November 13, 2025) Impact: Critical security and cost optimization features

Table of Contents

  1. Overview
  2. Row Level Security (RLS)
  3. Supabase Storage
  4. Setup Instructions
  5. Testing
  6. Architecture
  7. Cost Analysis

Overview

This implementation adds two critical features to the Archety backend:

  1. Row Level Security (RLS): Database-level privacy enforcement
  2. Supabase Storage: Simplified photo storage with automatic RLS

Why These Features Matter

RLS Prevents Data Leaks: - Privacy violations happen in the backend database, not the frontend - Protects against bugs in FastAPI code - Even if code has a bug (missing user_id filter), RLS prevents cross-user data access - Security enforced at the database layer (defense in depth)

Supabase Storage Simplifies Photo Management: - No AWS credentials or boto3 needed - Automatic RLS for photo privacy - Built-in image transformations (thumbnails) - Cheaper than S3 + CloudFront - Integrated with existing Supabase infrastructure


Row Level Security (RLS)

What is RLS?

Row Level Security is a PostgreSQL feature that restricts which rows users can access based on policies. Supabase makes it easy to implement RLS using JWT authentication.

Example: Preventing Data Leaks

Without RLS (vulnerable):

# Bug in code - no user_id filter!
@router.get("/messages")
async def get_messages(chat_guid: str, db: Session):
    messages = db.query(Message).filter(
        Message.chat_guid == chat_guid
    ).all()  # ❌ Any user can query any chat_guid!

With RLS (protected):

CREATE POLICY "Users see only their own messages"
ON messages FOR SELECT
USING (
  conversation_id IN (
    SELECT id FROM conversations
    WHERE participants @> jsonb_build_array(
      (SELECT phone FROM users WHERE supabase_user_id = auth.uid())
    )
  )
);

Now the bug can't leak data - database enforces privacy!

RLS Policies Implemented

1. Users Table

-- Users can only view/update their own profile
CREATE POLICY "Users can view own profile"
ON public.users FOR SELECT
USING (supabase_user_id = auth.uid());

CREATE POLICY "Users can update own profile"
ON public.users FOR UPDATE
USING (supabase_user_id = auth.uid());

2. Messages Table

-- Users can only see messages from conversations they're part of
CREATE POLICY "Users can view own messages"
ON public.messages FOR SELECT
USING (
  conversation_id IN (
    SELECT id FROM conversations
    WHERE participants @> jsonb_build_array(
      (SELECT phone FROM users WHERE supabase_user_id = auth.uid())
    )
  )
);

3. OAuth Tokens Table

-- Users can only manage their own OAuth tokens
CREATE POLICY "Users can view own oauth tokens"
ON public.oauth_tokens FOR SELECT
USING (user_id = (SELECT id FROM users WHERE supabase_user_id = auth.uid()));

4. Relationship States, Boundaries

-- Users can only see their own relationship data
CREATE POLICY "Users can view own relationship states"
ON public.relationship_states FOR SELECT
USING (user_id = (SELECT id FROM users WHERE supabase_user_id = auth.uid()));

CREATE POLICY "Users can view own group boundaries"
ON public.group_boundaries FOR SELECT
USING (user_id = (SELECT id FROM users WHERE supabase_user_id = auth.uid()));

5. Usage Events, Transactions, Notifications

-- Users can only see their own usage and billing data
CREATE POLICY "Users can view own usage events"
ON public.usage_events FOR SELECT
USING (user_id = (SELECT id FROM users WHERE supabase_user_id = auth.uid()));

CREATE POLICY "Users can view own credit transactions"
ON public.credit_transactions FOR SELECT
USING (user_id = (SELECT id FROM users WHERE supabase_user_id = auth.uid()));

CREATE POLICY "Users can view own notifications"
ON public.proactive_notifications FOR SELECT
USING (user_id = (SELECT id FROM users WHERE supabase_user_id = auth.uid()));

How RLS Works

  1. User authenticates via Twilio OTP
  2. Backend creates Supabase session with JWT
  3. User makes API request with Authorization: Bearer <token> header
  4. Backend validates JWT and extracts supabase_user_id
  5. Database queries use JWT via auth.uid() function
  6. RLS policies filter results automatically

Service Role Bypass

The backend uses SUPABASE_SERVICE_KEY (service role) for: - Creating users - Admin operations - Background jobs - Photo uploads on behalf of users

Service role bypasses RLS, allowing backend to perform admin operations.


Supabase Storage

Why Supabase Storage?

Before (S3): - AWS credentials required - boto3 client setup - Manual encryption - S3 + CloudFront costs - No automatic RLS

After (Supabase Storage): - No AWS credentials needed - Simple Python client - Automatic encryption - Free tier (5GB storage) - Automatic RLS

Storage Structure

user-photos/ (private bucket)
├── {user_id}/
│   ├── {photo_id}/
│   │   └── {filename}.jpg
│   └── ...

user-photos-thumbnails/ (public bucket)
├── {user_id}/
│   ├── {photo_id}/
│   │   └── {filename}_thumb.webp
│   └── ...

Storage RLS Policies

-- Users can only upload photos to their own folder
CREATE POLICY "Users can upload own photos"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'user-photos' AND
  (storage.foldername(name))[1] = (
    SELECT u.id::text FROM users u WHERE u.supabase_user_id = auth.uid()
  )
);

-- Users can only view photos from their own folder
CREATE POLICY "Users can view own photos"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'user-photos' AND
  (storage.foldername(name))[1] = (
    SELECT u.id::text FROM users u WHERE u.supabase_user_id = auth.uid()
  )
);

-- Users can delete their own photos
CREATE POLICY "Users can delete own photos"
ON storage.objects FOR DELETE
USING (
  bucket_id = 'user-photos' AND
  (storage.foldername(name))[1] = (
    SELECT u.id::text FROM users u WHERE u.supabase_user_id = auth.uid()
  )
);

Photo Upload Flow

# 1. User uploads photo via API
POST /photo/upload
Authorization: Bearer <jwt>
Content-Type: multipart/form-data

# 2. Backend stores in Supabase Storage
from app.storage.supabase_storage import get_supabase_storage

storage = get_supabase_storage()
result = storage.upload_photo(
    user_id=str(user.id),
    photo_id=photo_id,
    photo_data=image_bytes,
    content_type="image/jpeg"
)

# 3. Returns URL and path
{
    "photo_id": "abc123",
    "url": "https://xyz.supabase.co/storage/v1/object/public/user-photos/...",
    "path": "user_id/photo_id/filename.jpg"
}

# 4. Store metadata in database
photo_memory = PhotoMemory(
    id=photo_id,
    user_id=user.id,
    photo_url=result['url'],
    meta_data={'storage_path': result['path']}
)

Image Transformations

Supabase Storage supports automatic image transformations:

# Generate thumbnail
thumbnail_url = storage.generate_thumbnail(
    user_id=user_id,
    photo_id=photo_id,
    width=200,
    height=200
)
# Returns: .../photo.jpg?width=200&height=200&resize=cover&format=webp

Setup Instructions

1. Database Migrations

All RLS policies are applied via migrations:

# Migrations already applied in production
supabase/migrations/
├── 20251113_add_supabase_user_id_and_rls_policies.sql
└── 20251113_add_storage_rls_policies.sql

2. Environment Variables

Add to Railway (or .env):

# Supabase (required)
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_KEY=your-service-role-key

# No longer needed:
# AWS_ACCESS_KEY_ID (removed)
# AWS_SECRET_ACCESS_KEY (removed)
# PHOTO_S3_BUCKET (removed)

3. Supabase Dashboard Setup

  1. Create Storage Buckets:
  2. Go to Storage → Create bucket
  3. Name: user-photos (private)
  4. Name: user-photos-thumbnails (public)
  5. Max file size: 10MB
  6. Allowed MIME types: image/jpeg, image/png, image/webp, image/heic

  7. Enable Image Transformations:

  8. Go to Storage → Settings
  9. Enable "Image transformations"
  10. This allows automatic thumbnail generation

  11. Verify RLS Policies:

  12. Go to Authentication → Policies
  13. Should see policies for all tables
  14. Go to Storage → Policies
  15. Should see policies for both buckets

4. Update Frontend

Frontend needs to use JWT for API calls:

// After phone verification
const { access_token } = await verifyOTP(phone, code);

// Store token in HTTP-only cookie or localStorage
localStorage.setItem('access_token', access_token);

// Use in API calls
fetch('/api/user/profile', {
  headers: {
    'Authorization': `Bearer ${access_token}`
  }
});

5. Update Mac Mini Relay

Relay needs to use webhook secret:

# Mac mini relay
import requests

response = requests.post(
    "https://backend.archety.com/orchestrator/message",
    headers={
        "Authorization": f"Bearer {RELAY_WEBHOOK_SECRET}",
        "Content-Type": "application/json"
    },
    json=message_data
)

Testing

Run RLS Tests

# Run test suite
pytest tests/test_rls_policies.py -v

# Run summary
python tests/test_rls_policies.py

Manual Testing

  1. Create two test users:

    curl -X POST https://backend/auth/verify/start \
      -d '{"phone": "+15551111111"}'
    
    curl -X POST https://backend/auth/verify/confirm \
      -d '{"phone": "+15551111111", "code": "123456"}'
    # Save access_token_a
    
    curl -X POST https://backend/auth/verify/start \
      -d '{"phone": "+15552222222"}'
    
    curl -X POST https://backend/auth/verify/confirm \
      -d '{"phone": "+15552222222", "code": "123456"}'
    # Save access_token_b
    

  2. Test cross-user data access:

    # User A creates data
    curl -X PUT https://backend/user/profile \
      -H "Authorization: Bearer $access_token_a" \
      -d '{"name": "Alice", "pronouns": "she/her"}'
    
    # User A can read their own data
    curl https://backend/user/profile \
      -H "Authorization: Bearer $access_token_a"
    # ✅ Returns Alice's profile
    
    # User B cannot read User A's data
    curl https://backend/user/profile \
      -H "Authorization: Bearer $access_token_b"
    # ✅ Returns Bob's profile, NOT Alice's
    

  3. Test photo storage RLS:

    # User A uploads photo
    curl -X POST https://backend/photo/upload \
      -H "Authorization: Bearer $access_token_a" \
      -F "file=@photo.jpg" \
      -F "user_phone=+15551111111"
    # ✅ Upload succeeds, photo stored in User A's folder
    
    # User B tries to access User A's photo
    # RLS prevents this at the storage level
    


Architecture

Authentication Flow

┌─────────┐      ┌──────────┐      ┌───────────┐      ┌──────────┐
│ User    │─────>│ Twilio   │─────>│ Backend   │─────>│ Supabase │
│ (Phone) │ OTP  │ Verify   │ Code │ FastAPI   │ JWT  │ Auth     │
└─────────┘      └──────────┘      └───────────┘      └──────────┘
                                           │ Link internal user_id
                                           │ with supabase_user_id
                                    ┌──────────────┐
                                    │ public.users │
                                    │ ┌──────────┐ │
                                    │ │ id (UUID)│ │
                                    │ │ phone    │ │
                                    │ │ supabase_│ │
                                    │ │ user_id  │ │
                                    │ └──────────┘ │
                                    └──────────────┘

Data Access Flow

┌──────────┐      ┌──────────┐      ┌──────────┐      ┌──────────┐
│ Frontend │─────>│ Backend  │─────>│ Supabase │─────>│ Database │
│          │ JWT  │ FastAPI  │ JWT  │ PostgREST│ RLS  │ Postgres │
└──────────┘      └──────────┘      └──────────┘      └──────────┘
                                                             │ RLS Policy:
                                                             │ WHERE supabase_user_id = auth.uid()
                                                      ┌──────────────┐
                                                      │ Only user's  │
                                                      │ data returned│
                                                      └──────────────┘

Photo Storage Flow

┌──────────┐      ┌──────────┐      ┌──────────┐      ┌──────────┐
│ User     │─────>│ Backend  │─────>│ Supabase │─────>│ Storage  │
│          │ Photo│ FastAPI  │ Upload│ Storage  │ RLS  │ Bucket   │
└──────────┘      └──────────┘      └──────────┘      └──────────┘
                                                             │ Path:
                                                             │ user_id/photo_id/filename.jpg
                                                             │ RLS Policy:
                                                             │ foldername[1] = user.id
                                                      ┌──────────────┐
                                                      │ Photo stored │
                                                      │ in user      │
                                                      │ folder only  │
                                                      └──────────────┘

Cost Analysis

Before (S3 + Redis)

Service Cost/Month Notes
S3 Storage (5GB) $0.12 $0.023 per GB
S3 Requests $0.50 PUT/GET requests
CloudFront (CDN) $1.00 Data transfer
Redis (JWT storage) $5-10 Upstash or Railway
Total $6.62-11.62

After (Supabase)

Service Cost/Month Notes
Supabase Storage (5GB) $0 Free tier: 5GB
Supabase Auth $0 Free tier: 50K MAU
Supabase Database $0 Already using
Total $0

Annual Savings

  • Monthly savings: $6.62-11.62
  • Annual savings: $79.44-139.44
  • Plus: Simplified architecture, fewer moving parts

Security Benefits

1. Defense in Depth

Even if backend code has bugs, RLS prevents data leaks:

# Bug in code
messages = db.query(Message).all()  # ❌ Missing user filter!

# But RLS prevents leak
# Only returns messages user has access to

2. Automatic Enforcement

No need to manually add WHERE user_id = ? to every query:

# Before (error-prone)
messages = db.query(Message).filter(
    Message.user_id == user_id  # Easy to forget!
).all()

# After (automatic)
messages = db.query(Message).all()  # RLS handles filtering

3. Audit Trail

All access controlled by database policies: - Easy to audit - Easy to update - Centralized security logic


Troubleshooting

RLS Blocking Legitimate Access

Symptom: Backend can't query user data

Solution: Check if backend is using service role key:

# ✅ Correct - bypasses RLS
client = create_client(
    settings.supabase_url,
    settings.supabase_service_key  # Service role key
)

# ❌ Wrong - subject to RLS
client = create_client(
    settings.supabase_url,
    settings.supabase_anon_key  # Anon key
)

Photos Not Uploading

Symptom: 403 Forbidden on photo upload

Solution: Check bucket exists and RLS policies are correct:

-- Verify bucket
SELECT * FROM storage.buckets WHERE name = 'user-photos';

-- Verify policies
SELECT * FROM storage.policies WHERE bucket_id = 'user-photos';

JWT Validation Failing

Symptom: 401 Unauthorized on API calls

Solution: Check token and expiry:

# Verify token
from app.identity.supabase_client import get_supabase_auth

auth = get_supabase_auth()
user_info = auth.verify_token(token)

if not user_info:
    # Token invalid or expired
    # User needs to refresh

Production Checklist

  • Supabase URL and keys added to Railway
  • RLS migrations applied
  • Storage buckets created
  • Frontend updated to use JWT
  • Mac mini relay updated with webhook secret
  • RLS tests passing
  • Manual testing with two users completed
  • Photo upload/download working
  • Monitor Supabase dashboard for errors

References


Last Updated: November 13, 2025 Status: ✅ Production Ready