Skip to content
Blog

Firebase Storage in Flutter: Upload, Download, and Secure Files

A full walkthrough of cloud_firestore-style file handling with firebase_storage in Flutter - uploads with progress, downloads, listing, and security rules that hold up.

Published on • October 6, 2026

AI Assistant

Every app eventually needs somewhere to put files: avatars, attachments, exports, voice notes. Firebase Cloud Storage gives Flutter apps a managed bucket with SDK support, and the FlutterFire firebase_storage plugin makes it feel like Dart. The API is small - but the difference between a demo and production is in metadata, progress, and rules.

Setup

Add the plugin alongside core Firebase:

dependencies:
  firebase_core: ^4.1.1
  firebase_storage: ^12.4.9

Initialize as usual (await Firebase.initializeApp()), then reach the default bucket:

final storage = FirebaseStorage.instance;
final ref = storage.ref();           // bucket root
final imagesRef = ref.child('images'); // folder-like path

Everything in Storage is a Reference - a path into a bucket, whether or not it exists yet.

Uploading with progress

From a file

Future<TaskSnapshot> uploadFile(File file, String userId) async {
  final fileRef = storage.ref('avatars/$userId.jpg');
  final uploadTask = fileRef.putFile(
    file,
    SettableMetadata(
      contentType: 'image/jpeg',
      customMetadata: {'uploadedBy': userId},
    ),
  );

  uploadTask.snapshotEvents.listen((snap) {
    final pct = (snap.bytesTransferred / snap.totalBytes * 100).toStringAsFixed(0);
    debugPrint('upload: $pct%');
  });

  return uploadTask;
}

putFile returns a UploadTask, which is both awaitable and listenable - wire snapshotEvents to a StreamBuilder for a progress bar that reflects real bytes, not a fake timer.

From bytes or a stream

final bytes = await picked.readAsBytes(); // e.g. after image_picker
await storage.ref('uploads/${name}').putData(bytes);

For large files (video, exports), prefer a stream so memory stays flat:

await ref.putStream(filePath.openRead());

Downloading

Three patterns cover most needs:

// 1. Temporary local file (for Image.file, video players, etc.)
final url = await ref.getDownloadURL();
final response = await http.get(Uri.parse(url));
final local = File('${dir.path}/avatar.jpg');
await local.writeAsBytes(response.bodyBytes);

// 2. Direct URL - let cached_network_image handle it
Image.network(url);

// 3. Full download task with progress
final task = ref.writeToFile(localFile);
final snap = await task;

Note the subtlety: getDownloadURL() returns a signed URL - the object itself isn’t public just because you have the URL string. Rules still gate access, and URLs expire.

Listing and deleting

final result = await storage.ref('uploads/$userId').listAll();

for (final item in result.items) {
  final meta = await item.getMetadata();
  debugPrint('${item.name}: ${meta.size} bytes, ${meta.updated}');
}

// Delete
await storage.ref('uploads/$userId/old.jpg').delete();

listAll() paginates internally; for buckets with thousands of objects, page manually with list(pageSize:) and follow pageToken.

Metadata is your friend

FullMetadata carries content type, size, custom metadata, cache-control headers, and generation IDs. Use it for:

  • Validation - reject uploads whose contentType isn’t allowlisted.
  • Deduplication - hash the file and use the hash in the path; check getMetadata() before overwriting.
  • Provenance - customMetadata stores the uploader UID so Cloud Functions and rules audits can attribute objects.

Security rules: where apps actually break

Storage rules run server-side before any operation. A permissive rule like allow write: if true is a billing incident waiting to happen - anyone with your bucket URL can fill it.

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // Public read for published assets
    match /public/{allPaths=**} {
      allow read: if true;
      allow write: if request.auth != null && isAdmin();
    }

    // Users manage only their own folder
    match /uploads/{userId}/{fileName} {
      allow read: if request.auth != null;
      allow write: if request.auth != null
        && request.auth.uid == userId
        && request.resource.size < 5 * 1024 * 1024
        && request.resource.contentType.matches('image/.*');
      allow delete: if request.auth != null && request.auth.uid == userId;
    }
  }
}

Every dimension matters:

  • Owner check (request.auth.uid == userId) - the essential rule.
  • Size caps - request.resource.size stops 2GB “uploads” cold.
  • Content type - an image bucket shouldn’t accept executables.
  • Filename hygiene - reject names containing /, .., or control characters to avoid path confusion.

Test rules with the Storage emulator (firebase emulators:start --only storage) - writing rules without testing them is guessing.

Pairing with App Check

Client-side rules can be abused by scripted traffic using stolen tokens. Enabling App Check (Play Integrity on Android, App Attest/DeviceCheck on iOS) ties requests to your genuine app binary. For Storage, enforce App Check alongside Auth - it removes the “my rules are fine but bots still hit me” failure mode.

Upload flows that feel native

A production upload screen usually needs:

class UploadController extends StateNotifier<UploadState> {
  Future<void> pickAndUpload() async {
    final picked = await ImagePicker().pickImage(
      source: ImageSource.gallery,
      maxWidth: 1600,
      imageQuality: 85, // compress before you pay for bandwidth
    );
    if (picked == null) return;

    state = const UploadState.uploading(progress: 0);
    final task = storage.ref('uploads/$uid/${name}').putFile(
      File(picked.path),
      SettableMetadata(contentType: 'image/jpeg'),
    );

    await for (final snap in task.snapshotEvents) {
      if (snap.totalBytes > 0) {
        state = UploadState.uploading(
          progress: snap.bytesTransferred / snap.totalBytes,
        );
      }
    }

    final url = await task.then((s) => s.ref.getDownloadURL());
    state = UploadState.done(url);
  }
}

Compression before upload (imageQuality, maxWidth) typically cuts transfer by 70% or more - the cheapest performance win in mobile.

Key Takeaways

  1. Everything in Storage is a Reference; putFile/putData/putStream return tasks you can both await and listen to for real progress.
  2. getDownloadURL() yields signed URLs - access is still governed by rules.
  3. Rules are the security boundary: enforce ownership, size, and content type, and test them in the emulator.
  4. App Check plus Auth closes the scripted-abuse gap that rules alone leave open.
  5. Compress and validate before upload - bandwidth, storage, and your bill all shrink.

References: