Execute Supabase secondary workflow: Auth + Storage + Realtime.
Use when implementing secondary use case,
or complementing primary workflow.
Trigger with phrases like "supabase auth storage realtime",
"implement full stack features with supabase".
Implement the three pillars that turn a Supabase database into a full application backend: user authentication (email/password, OAuth, magic links, session lifecycle), file storage (uploads, downloads, signed URLs, bucket-level RLS policies), and real-time subscriptions (Postgres change events, client-to-client broadcast, presence tracking). Every operation integrates with Row-Level Security through auth.uid().
Bucket RLS policies — enforce access control in SQL migrations:
-- Create buckets (run in a migration or SQL editor)
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true); -- public: anyone can read
INSERT INTO storage.buckets (id, name, public)
VALUES ('documents', 'documents', false); -- private: signed URLs only
-- Allow authenticated users to upload to their own folder
-- Convention: store files at <user_id>/filename.ext
CREATE POLICY "avatar_upload"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Allow anyone to view avatars (public bucket)
CREATE POLICY "avatar_public_read"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
-- Allow users to manage only their own documents (all operations)
CREATE POLICY "documents_user_crud"
ON storage.objects FOR ALL
USING (
bucket_id = 'documents'
AND auth.uid()::text = (storage.foldername(name))[1]
)
WITH CHECK (
bucket_id = 'documents'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Allow users to delete only files they uploaded
CREATE POLICY "documents_owner_delete"
ON storage.objects FOR DELETE
USING (
bucket_id = 'documents'
AND auth.uid() = owner
);
Python
# Upload
with open("report.pdf", "rb") as f:
result = supabase.storage.from_("documents").upload(
"user123/report.pdf", f,
{"content-type": "application/pdf", "cache-control": "3600"}
)
# Download
data = supabase.storage.from_("documents").download("user123/report.pdf")
# Public URL
url = supabase.storage.from_("avatars").get_public_url("user123/avatar.png")
# Signed URL (3600 seconds)
result = supabase.storage.from_("documents").create_signed_url(
"user123/report.pdf", 3600
)
signed_url = result["signedURL"]
# List files
files = supabase.storage.from_("documents").list("user123")
# Delete
supabase.storage.from_("documents").remove(["user123/old-file.pdf"])
Step 3: Realtime — Postgres Changes, Broadcast, and Presence
Supabase Realtime provides three channel types: database change listeners, client-to-client broadcast, and presence tracking for online status.
Postgres Changes (listen to INSERT/UPDATE/DELETE on tables):
// Subscribe to new messages in a chat table
const channel = supabase
.channel('chat-room')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
},
(payload) => {
console.log('New message:', payload.new)
// payload.new → full row data
// payload.old → null for INSERT
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'messages',
filter: 'room_id=eq.42', // server-side filter
},
(payload) => {
console.log('Updated:', payload.old, '→', payload.new)
}
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'messages',
},
(payload) => {
console.log('Deleted:', payload.old)
// payload.new → null for DELETE
}
)
.subscribe((status) => {
console.log('Channel status:', status)
// 'SUBSCRIBED' | 'TIMED_OUT' | 'CLOSED' | 'CHANNEL_ERROR'
})
// Enable Realtime on the table (required one-time setup in SQL)
// ALTER PUBLICATION supabase_realtime ADD TABLE messages;
// Unsubscribe when done
supabase.removeChannel(channel)
-- Only receive changes for rows the user owns
CREATE POLICY "users_own_messages"
ON messages FOR SELECT
USING (auth.uid() = user_id);
-- The Realtime listener will only fire for rows passing this policy
Broadcast (client-to-client, no database involved):
const room = supabase.channel('collab-room')
// Listen for cursor movements from other users
room.on('broadcast', { event: 'cursor-move' }, ({ payload }) => {
console.log(`User ${payload.userId} at (${payload.x}, ${payload.y})`)
})
// Subscribe first, then send
room.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// Send cursor position to all other clients
room.send({
type: 'broadcast',
event: 'cursor-move',
payload: { userId: 'abc', x: 120, y: 340 },
})
}
})
Auth: user registration, password login, OAuth flow (Google/GitHub), magic link, session lifecycle with onAuthStateChange, password reset
Storage: file upload/download, public URLs for CDN-served assets, time-limited signed URLs for private files, bucket-level RLS policies using auth.uid() and storage.foldername()
Realtime: Postgres change subscriptions with server-side filters, broadcast channels for client-to-client messaging, presence tracking for online status
Error Handling
Error
Cause
Solution
AuthApiError: User already registered
Duplicate email signup
Use signInWithPassword or check existence first
AuthApiError: Invalid login credentials
Wrong email or password
Verify credentials; check email confirmation status
AuthApiError: Email not confirmed
User has not clicked confirmation link
Resend with resend({ type: 'signup', email })
StorageApiError: Bucket not found
Bucket does not exist
Create via dashboard or INSERT INTO storage.buckets
StorageApiError: new row violates row-level security
RLS policy blocking the operation
Verify storage.objects policies match the user and bucket
StorageApiError: The resource already exists
File exists and upsert: false
Set upsert: true to overwrite or use a unique path
Realtime: channel error or TIMED_OUT
Network issues or Realtime not enabled
Check ALTER PUBLICATION supabase_realtime ADD TABLE <name>
Realtime: too many channels
Exceeded concurrent channel limit
Unsubscribe unused channels with removeChannel()
Examples
Full auth + protected upload flow:
// 1. Sign in
const { data: { session } } = await supabase.auth.signInWithPassword({
email: '[email protected]',
password: 'secure-password-123',
})
// 2. Upload avatar to user's folder (RLS enforces ownership)
const { data } = await supabase.storage
.from('avatars')
.upload(`${session.user.id}/avatar.png`, file, { upsert: true })
// 3. Get public URL for display
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${session.user.id}/avatar.png`)
// 4. Subscribe to profile changes in real time
const channel = supabase
.channel('profile-updates')
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'profiles',
filter: `id=eq.${session.user.id}`,
}, (payload) => {
console.log('Profile updated:', payload.new)
})
.subscribe()