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
contentTypeisn’t allowlisted. - Deduplication - hash the file and use the hash in the path; check
getMetadata()before overwriting. - Provenance -
customMetadatastores 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.sizestops 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
- Everything in Storage is a
Reference;putFile/putData/putStreamreturn tasks you can both await and listen to for real progress. getDownloadURL()yields signed URLs - access is still governed by rules.- Rules are the security boundary: enforce ownership, size, and content type, and test them in the emulator.
- App Check plus Auth closes the scripted-abuse gap that rules alone leave open.
- Compress and validate before upload - bandwidth, storage, and your bill all shrink.
References: