Mobile Development
FlutterSupabaseStorageSecurityMobileGuide

Flutter + Supabase File Uploads: Secure Storage, Signed URLs, Image Resizing, and Access Control

AO
Adrijan Omićević
·14 min read

# 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#

RequirementVersionNotes
Flutter3.22+Works on iOS and Android
Dart3.4+Included with Flutter
Supabase projectLatestStorage enabled
supabase_flutter2.xAuth + Storage client
image_picker1.xCamera and gallery
flutter_image_compress2.xResize and compress
path1.xPath ops
sqflite or hiveLatestPersist upload queue
connectivity_plusLatestNetwork 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:

  1. 1
    Storage bucket holds raw bytes and enforces object-level rules.
  2. 2
    Postgres table tracks metadata and enforces who can reference which object.
  3. 3
    Signed 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: media with owner_id, bucket, path, mime_type, bytes, width, height, created_at

Example table:

ColumnTypePurpose
iduuidPrimary key
owner_iduuidauth.uid()
buckettextUsually user_uploads
pathtextStorage object key
mime_typetextFor rendering and validation
bytesbigintQuota and UI display
widthintImage rendering optimizations
heightintImage rendering optimizations
created_attimestamptzAuditing

# 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.

SQL
-- 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.

SQL
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:

Bash
flutter pub add supabase_flutter image_picker flutter_image_compress path uuid sqflite connectivity_plus

Initialize Supabase:

Dart
// 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());
}

A clean UX offers both camera capture and gallery pick, then runs the same processing pipeline:

  1. 1
    Pick or capture image
  2. 2
    Validate size and type
  3. 3
    Resize and compress
  4. 4
    Upload to Storage
  5. 5
    Insert metadata row in Postgres
  6. 6
    Generate signed URL for display

Picking an image#

Dart
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: 100 here 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#

Dart
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:

SettingRecommended starting pointWhy it matters
Long edge1280 to 2048 pxFits most feeds without waste
Quality75 to 85Best tradeoff for mobile
FormatWebP on Android, JPEG fallback on iOSCompatibility and size
Max upload bytes5 MB to 10 MBPrevents 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.

Dart
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#

Dart
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.

Dart
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:

FieldTypeExampleWhy
idtextUUIDUnique job
local_pathtext/data/user/.../tmp.webpWhere file is
mime_typetextimage/webpNeeded for upload
statustextqueued or uploading or failedUI and logic
attemptsint0..NBackoff
next_retry_atintepoch msScheduling
last_errortextSocketExceptionDebugging

Exponential backoff function#

Dart
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.

Dart
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:

  1. 1
    Persist queue
  2. 2
    Process on app open, resume, and connectivity regain
  3. 3
    For Android-heavy apps, consider WorkManager
  4. 4
    Keep 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#

Dart
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_members table with RLS
  • Storage select policy checks membership
  • Database media row 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.

FailureSymptomWhat to do
Not authenticated401 or null userForce re-login, pause queue
Policy denied403Log path and user id, verify policy and naming scheme
Network downSocketExceptionRetry with backoff
Timeoutslow uploadReduce image size, retry
File missinglocal cleanupMark failed permanently and ask user to reselect
Duplicate pathrare with UUIDRegenerate and retry

Mapping errors in Dart#

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:

VariantPath exampleUse case
originalusers/<uid>/<id>.jpgarchive
thumbusers/<uid>/<id>_thumb.webplists
mediumusers/<uid>/<id>_md.webpfeed

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:

  1. 1
    The media row
  2. 2
    The 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:

Dart
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:

  1. 1
    Upload from camera and gallery on both platforms
  2. 2
    Turn on airplane mode mid-upload and confirm retry queue behavior
  3. 3
    Force close the app during upload and confirm job resumes later
  4. 4
    Validate a user cannot read another user’s files by changing path
  5. 5
    Confirm signed URLs expire and refresh logic works
  6. 6
    Upload 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 media table 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

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.