Supabase RLS and Storage Implementation Guide¶
Status: ✅ Fully Implemented (November 13, 2025) Impact: Critical security and cost optimization features
Table of Contents¶
- Overview
- Row Level Security (RLS)
- Supabase Storage
- Setup Instructions
- Testing
- Architecture
- Cost Analysis
Overview¶
This implementation adds two critical features to the Archety backend:
- Row Level Security (RLS): Database-level privacy enforcement
- 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¶
- User authenticates via Twilio OTP
- Backend creates Supabase session with JWT
- User makes API request with
Authorization: Bearer <token>header - Backend validates JWT and extracts
supabase_user_id - Database queries use JWT via
auth.uid()function - 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¶
- Create Storage Buckets:
- Go to Storage → Create bucket
- Name:
user-photos(private) - Name:
user-photos-thumbnails(public) - Max file size: 10MB
-
Allowed MIME types:
image/jpeg, image/png, image/webp, image/heic -
Enable Image Transformations:
- Go to Storage → Settings
- Enable "Image transformations"
-
This allows automatic thumbnail generation
-
Verify RLS Policies:
- Go to Authentication → Policies
- Should see policies for all tables
- Go to Storage → Policies
- 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¶
-
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 -
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 -
Test photo storage RLS:
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¶
- Supabase RLS Documentation
- Supabase Storage Documentation
- PostgreSQL RLS Documentation
- Archety SUPABASE_SETUP.md
Last Updated: November 13, 2025 Status: ✅ Production Ready