# What You’ll Build#
This guide shows a production-ready Flutter Supabase file upload setup for private user content: profile photos, receipts, and chat images. You’ll implement camera and gallery uploads, on-device image resizing, resilient background retries, and secure downloads using short-lived signed URLs.
You’ll also set up Supabase Storage policies for write and read access, connect files to database rows with RLS, and handle real-world failures like flaky networks and partial uploads.
ℹ️ Note: Supabase Storage access control is enforced via Storage policies in Postgres. RLS on your own tables is separate, but you typically combine both to prevent broken authorization chains.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Flutter | 3.22+ | Works on iOS and Android |
| Dart | 3.4+ | Included with Flutter |
| Supabase project | Latest | Storage enabled |
| supabase_flutter | 2.x | Auth + Storage client |
| image_picker | 1.x | Camera and gallery |
| flutter_image_compress | 2.x | Resize and compress |
| path | 1.x | Path ops |
| sqflite or hive | Latest | Persist upload queue |
| connectivity_plus | Latest | Network state (optional) |
If you’re also implementing auth, realtime, and offline syncing patterns, pair this post with Flutter + Supabase Auth, Realtime, and Offline Sync. For hardened mobile security assumptions, read Flutter App Security Hardening.
# Data Model and Bucket Strategy#
A secure architecture starts with a clear separation of concerns:
- 1Storage bucket holds raw bytes and enforces object-level rules.
- 2Postgres table tracks metadata and enforces who can reference which object.
- 3Signed URLs provide time-limited read access without making the bucket public.
A practical baseline:
- Bucket name:
user_uploads(private) - Object path:
users/<user_id>/<uuid>.<ext> - Table:
mediawithowner_id,bucket,path,mime_type,bytes,width,height,created_at
Example table:
| Column | Type | Purpose |
|---|---|---|
| id | uuid | Primary key |
| owner_id | uuid | auth.uid() |
| bucket | text | Usually user_uploads |
| path | text | Storage object key |
| mime_type | text | For rendering and validation |
| bytes | bigint | Quota and UI display |
| width | int | Image rendering optimizations |
| height | int | Image rendering optimizations |
| created_at | timestamptz | Auditing |
# Supabase Setup: Buckets, Policies, and RLS#
1) Create a private bucket#
In Supabase Dashboard:
- Storage → Buckets → New bucket →
user_uploads - Set bucket to private
- Optional: enable MIME type restrictions if you have a known set
Why private matters: public buckets turn authorization into an app-only problem. Signed URLs keep access controlled server-side and time-limited.
2) Storage policies for upload and read#
Supabase Storage uses Postgres policies on storage.objects. You typically allow:
- Insert: only authenticated users, only into their own folder
- Select: only authenticated users, only their own folder
- Update and delete: same rule as insert, if you need it
A common policy pattern checks the first path segment after users/ matches the authenticated user id.
-- Allow authenticated users to upload only into users/<uid>/...
create policy "User can upload to own folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'user_uploads'
and (storage.foldername(name))[1] = 'users'
and (storage.foldername(name))[2] = auth.uid()::text
);
-- Allow authenticated users to read only their own files
create policy "User can read own files"
on storage.objects
for select
to authenticated
using (
bucket_id = 'user_uploads'
and (storage.foldername(name))[1] = 'users'
and (storage.foldername(name))[2] = auth.uid()::text
);⚠️ Warning: If you use a path format like
users/<uid>/..., make sure every client upload follows it. A single mismatch will look like a random 403 in production.
3) RLS for the metadata table#
Enable RLS on media and add owner-based policies.
alter table public.media enable row level security;
create policy "Media is readable by owner"
on public.media
for select
to authenticated
using (owner_id = auth.uid());
create policy "Media is insertable by owner"
on public.media
for insert
to authenticated
with check (owner_id = auth.uid());
create policy "Media is deletable by owner"
on public.media
for delete
to authenticated
using (owner_id = auth.uid());This ensures a user can only create and query metadata for their own files, preventing enumeration through your API even if they guess IDs.
Why you need both Storage policies and RLS#
If you only lock Storage but not media, users can still query metadata and learn paths, MIME types, and timestamps. If you only lock media but not Storage, users might be able to fetch raw files directly. You want both layers.
# Flutter Client: Dependencies and Initialization#
Add packages:
flutter pub add supabase_flutter image_picker flutter_image_compress path uuid sqflite connectivity_plusInitialize Supabase:
// main.dart
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'https://YOUR_PROJECT.supabase.co',
anonKey: 'YOUR_ANON_KEY',
);
runApp(const MyApp());
}# Upload Flow: Camera and Gallery#
A clean UX offers both camera capture and gallery pick, then runs the same processing pipeline:
- 1Pick or capture image
- 2Validate size and type
- 3Resize and compress
- 4Upload to Storage
- 5Insert metadata row in Postgres
- 6Generate signed URL for display
Picking an image#
import 'package:image_picker/image_picker.dart';
final _picker = ImagePicker();
Future<XFile?> pickFromGallery() {
return _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 100,
);
}
Future<XFile?> captureFromCamera() {
return _picker.pickImage(
source: ImageSource.camera,
preferredCameraDevice: CameraDevice.rear,
imageQuality: 100,
);
}💡 Tip: Use
imageQuality: 100here and control compression yourself. Relying on picker compression makes output inconsistent across devices.
# Image Resizing and Compression (On-device)#
Uploading full-resolution photos is one of the fastest ways to inflate storage and bandwidth costs. A typical modern phone photo is 3 MB to 8 MB and 12 MP to 48 MP. If you resize to 1600 px on the long edge and compress to WebP or JPEG, you can often cut bytes by 70% to 90% with negligible UI impact for avatars and feeds.
Resizing example#
import 'dart:io';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path/path.dart' as p;
Future<File> compressForUpload(File input) async {
final dir = input.parent;
final outPath = p.join(
dir.path,
'${p.basenameWithoutExtension(input.path)}_upload.webp',
);
final result = await FlutterImageCompress.compressAndGetFile(
input.path,
outPath,
format: CompressFormat.webp,
quality: 82,
minWidth: 1600,
minHeight: 1600,
);
if (result == null) {
throw Exception('Image compression failed');
}
return File(result.path);
}Production decisions you should make explicitly:
| Setting | Recommended starting point | Why it matters |
|---|---|---|
| Long edge | 1280 to 2048 px | Fits most feeds without waste |
| Quality | 75 to 85 | Best tradeoff for mobile |
| Format | WebP on Android, JPEG fallback on iOS | Compatibility and size |
| Max upload bytes | 5 MB to 10 MB | Prevents abuse and timeouts |
# Uploading to Supabase Storage#
Create a stable object path#
Keep paths deterministic and scoped to the user id. Use a UUID filename to avoid collisions and information leakage.
import 'package:uuid/uuid.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
final supabase = Supabase.instance.client;
final uuid = const Uuid();
String buildObjectPath({
required String userId,
required String extension,
}) {
final id = uuid.v4();
return 'users/$userId/$id.$extension';
}Upload bytes with content type#
import 'dart:io';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<String> uploadFile({
required File file,
required String mimeType,
}) async {
final user = supabase.auth.currentUser;
if (user == null) throw Exception('Not authenticated');
final ext = file.path.split('.').last.toLowerCase();
final path = buildObjectPath(userId: user.id, extension: ext);
await supabase.storage
.from('user_uploads')
.upload(
path,
file,
fileOptions: FileOptions(
contentType: mimeType,
upsert: false,
),
);
return path;
}Insert metadata row after upload#
Treat the Storage upload as the source of truth for bytes existing. Insert metadata only when upload succeeds.
Future<String> createMediaRow({
required String path,
required String mimeType,
required int bytes,
}) async {
final user = supabase.auth.currentUser;
if (user == null) throw Exception('Not authenticated');
final res = await supabase
.from('media')
.insert({
'owner_id': user.id,
'bucket': 'user_uploads',
'path': path,
'mime_type': mimeType,
'bytes': bytes,
})
.select('id')
.single();
return res['id'] as String;
}🎯 Key Takeaway: Upload first, then insert metadata. If you insert first and the upload fails, you create dangling records that complicate cleanup and leak UI state.
# Background Retries: Building an Upload Queue#
Mobile uploads fail for reasons that do not show up in local testing: elevators, tunnels, radio handovers, captive portals, and low-memory kills. The minimum viable reliability layer is an upload queue persisted to local storage, retried with exponential backoff.
Queue design#
Store enough state to retry without UI context:
| Field | Type | Example | Why |
|---|---|---|---|
| id | text | UUID | Unique job |
| local_path | text | /data/user/.../tmp.webp | Where file is |
| mime_type | text | image/webp | Needed for upload |
| status | text | queued or uploading or failed | UI and logic |
| attempts | int | 0..N | Backoff |
| next_retry_at | int | epoch ms | Scheduling |
| last_error | text | SocketException | Debugging |
Exponential backoff function#
int computeBackoffSeconds(int attempts) {
final base = 2;
final maxSeconds = 300;
final seconds = base * (1 << (attempts.clamp(0, 8)));
return seconds > maxSeconds ? maxSeconds : seconds;
}Worker loop sketch#
This is intentionally simple and works even without full background execution. You can run it on app launch, resume, and after connectivity changes.
Future<void> processQueueOnce() async {
final jobs = await db.fetchDueJobs(limit: 3);
for (final job in jobs) {
try {
await db.markUploading(job.id);
final file = File(job.localPath);
final path = await uploadFile(file: file, mimeType: job.mimeType);
await createMediaRow(
path: path,
mimeType: job.mimeType,
bytes: await file.length(),
);
await db.markDone(job.id);
} catch (e) {
final attempts = job.attempts + 1;
final backoff = computeBackoffSeconds(attempts);
await db.markFailed(
job.id,
attempts: attempts,
nextRetryAt: DateTime.now().add(Duration(seconds: backoff)),
lastError: e.toString(),
);
}
}
}Background execution reality check#
iOS severely limits always-on background tasks. Android is more flexible but still subject to OEM battery policies. A practical approach:
- 1Persist queue
- 2Process on app open, resume, and connectivity regain
- 3For Android-heavy apps, consider WorkManager
- 4Keep jobs small by compressing first
If you need a server-managed upload pipeline, look at the general presigned URL pattern in Next.js File Uploads with Presigned URLs and adapt the architecture for your mobile backend.
# Secure Downloads with Signed URLs#
Private buckets require signed URLs for read access. The app requests a URL that expires quickly, then uses it in an image widget or HTTP client.
Create a signed URL#
Future<String> createSignedUrl({
required String path,
int expiresInSeconds = 60,
}) async {
final res = await supabase.storage
.from('user_uploads')
.createSignedUrl(path, expiresInSeconds);
return res;
}Displaying an image with a short-lived URL#
In practice, you should cache the signed URL in memory for its TTL to avoid re-signing on every rebuild. If you use an image caching library, ensure it respects query strings and expiration.
A common pattern:
- Fetch signed URL when the widget becomes visible
- Refresh if it fails with 401 or 403
- Avoid long TTLs for sensitive media
⚠️ Warning: Do not set signed URL expiry to hours for private user data. A leaked URL stays valid until it expires, and mobile logs, proxies, or crash reports can leak it.
# Access Control Patterns That Hold Up in Production#
Pattern A: User-only files#
Use path users/<uid>/... and policies shown earlier. This covers profile pictures, personal documents, and private exports.
Pattern B: Shared files, like chats or teams#
You need a join table that defines membership, and Storage policies must reference it. Supabase policies can query other tables, so you can enforce that a user can read a file only if they are part of the chat or team that owns it.
Keep the object path aligned with ownership, for example teams/<team_id>/..., and implement:
team_memberstable with RLS- Storage select policy checks membership
- Database
mediarow links to team id and has RLS based on membership
This is the point where authorization complexity grows quickly. If your app has strong privacy requirements, align with hardening practices in Flutter App Security Hardening and assume clients can be tampered with.
# Practical Error Handling: What to Catch and What to Show#
Most upload failures fall into a small set of categories. Handle them explicitly so your UI stays predictable.
| Failure | Symptom | What to do |
|---|---|---|
| Not authenticated | 401 or null user | Force re-login, pause queue |
| Policy denied | 403 | Log path and user id, verify policy and naming scheme |
| Network down | SocketException | Retry with backoff |
| Timeout | slow upload | Reduce image size, retry |
| File missing | local cleanup | Mark failed permanently and ask user to reselect |
| Duplicate path | rare with UUID | Regenerate and retry |
Mapping errors in Dart#
String userMessageFromError(Object e) {
final msg = e.toString().toLowerCase();
if (msg.contains('not authenticated') || msg.contains('jwt')) {
return 'Please sign in again to upload files.';
}
if (msg.contains('403') || msg.contains('permission')) {
return 'Upload blocked by permissions. Please contact support.';
}
if (msg.contains('socketexception') || msg.contains('network')) {
return 'No internet connection. Upload will retry automatically.';
}
return 'Upload failed. We will retry in the background.';
}# Image Variants and Resizing Strategy#
You have two main options:
Option 1: Resize on-device and upload one variant#
Best for simplicity and cost. Works well for avatars and typical app feeds.
- Upload: 1 file
- CDN: smaller bytes
- Server: no processing required
Option 2: Upload original and generate variants#
Best when you need multiple sizes or future-proofing. You can implement variants via an automation workflow that creates thumbnails and stores them alongside the original.
A simple naming scheme:
| Variant | Path example | Use case |
|---|---|---|
| original | users/<uid>/<id>.jpg | archive |
| thumb | users/<uid>/<id>_thumb.webp | lists |
| medium | users/<uid>/<id>_md.webp | feed |
If you generate variants server-side, keep the bucket private and apply the same policy rules, or generate into a separate private bucket.
💡 Tip: If you implement server-side resizing, avoid doing it in the mobile client. Use one canonical upload and let automation create derivatives for consistency.
# Cleanup and Lifecycle: Deleting Files Safely#
When users delete a post or replace an avatar, remove both:
- 1The
mediarow - 2The Storage object
Order matters for recoverability. In many apps, delete the DB record first, then attempt Storage delete. If Storage delete fails, schedule a cleanup job.
A delete example:
Future<void> deleteMedia({
required String mediaId,
required String path,
}) async {
await supabase.from('media').delete().eq('id', mediaId);
await supabase.storage.from('user_uploads').remove([path]);
}# Testing Checklist#
Test your pipeline under real constraints:
- 1Upload from camera and gallery on both platforms
- 2Turn on airplane mode mid-upload and confirm retry queue behavior
- 3Force close the app during upload and confirm job resumes later
- 4Validate a user cannot read another user’s files by changing path
- 5Confirm signed URLs expire and refresh logic works
- 6Upload the largest realistic photo your users produce and measure time
For broader offline-first architecture, see Flutter + Supabase Auth, Realtime, and Offline Sync.
# Key Takeaways#
- Keep the bucket private, enforce Storage policies by path prefix, and use short-lived signed URLs for downloads.
- Combine Storage policies with RLS on a
mediatable so users can neither access bytes nor metadata they do not own. - Compress and resize images on-device to reduce upload bytes by 70% to 90% for typical mobile photos.
- Implement an upload queue with persisted jobs and exponential backoff to survive flaky networks and app kills.
- Upload first, then insert metadata, and handle common failures explicitly to avoid dangling records and confusing UI.
# Conclusion#
A robust Flutter Supabase file upload flow is mostly about consistency and defense-in-depth: predictable object paths, strict policies, signed URLs, and retries that assume the network will fail. If you want Samioda to implement secure uploads, background retries, and media pipelines end-to-end in your Flutter app, contact us and we’ll help you ship it with production-grade access control and observability.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Mobile Development
All →Flutter Testing Strategy: Unit, Widget, Integration and Golden Tests for Fast, Reliable CI (2026)
A practical Flutter testing strategy built around the testing pyramid: when to use unit, widget, integration, and golden tests, how to reduce flakes, and how to run everything fast in CI.
Flutter Navigation with go_router: Deep Links, Auth Guards, Nested Routes, and Web Support
A production-ready guide to Flutter go_router deep links, including auth redirects, ShellRoute layouts, nested navigation, URL-driven state, and deep link testing for iOS, Android, and web.
Flutter App Security Hardening: SSL Pinning, Root and Jailbreak Detection, and Secure Storage (2026 Guide)
A practical guide to Flutter app security hardening with a threat-model checklist, secure storage patterns, SSL pinning tradeoffs, runtime integrity checks, and safe auth token handling.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Flutter Push Notifications in Production: FCM + APNs, Deep Links, and Reliability (2026 Guide)
An end-to-end production guide to Flutter push notifications using FCM and APNs: setup, token lifecycle, segmentation, deep linking, background and terminated handling, plus reliability and troubleshooting checklists.
Flutter + Supabase vs Firebase in 2026: Auth, Realtime, Offline, Pricing, and Lock-In
A practical 2026 comparison of Flutter with Supabase vs Firebase across auth, push, realtime, offline/local-first, storage, functions, pricing, and vendor lock-in — with recommendations by app type and scale.
Flutter + Supabase in Production: Auth, Realtime, RLS, and Offline-Friendly Data Access (2026 Guide)
A production-ready guide to Flutter Supabase auth realtime offline sync: secure auth flows, Row Level Security patterns, realtime subscriptions, and offline-first UX with practical code and gotchas.