Upload Files to AWS S3 from Flutter (Web + Mobile)

So, in this article, I will be showing you how you can upload files to AWS S3 from a Flutter app, on both web and mobile.
The naive way to do this — the one that appears in half the tutorials — is to paste your AWS access key and secret into the app and call putObject directly. Do not do that. Anyone who decompiles your app (or opens the browser's dev tools on the web build) gets your secret key, and then they own your bucket. That is a production incident waiting for an intern to find.
The correct way is a presigned URL: your backend signs a short-lived URL, your Flutter app uploads the file directly to that URL, and the secret never leaves your server. This is the flow I have shipped for real, and it works identically on Android, iOS, and the web — with one web-specific caveat I will show you below.
I ran into this on a client's delivery app where drivers photograph parcels and the photos needed to land in S3 for the backend to process. The first version uploaded via a backend proxy; the client's data bill was painful, so we moved to direct presigned uploads. This article is that exact implementation.
Let's jump into the coding part.
Step 1: Add the Dependencies
You need http for the upload, and — if your app talks to your backend to fetch the presigned URL — http covers that too. That is the entire dependency list:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
On web, one extra thing: the browser's XMLHttpRequest enforces CORS, so your S3 bucket must be configured to allow uploads from your app's origin. More on that in the pitfalls.
Step 2: The Backend — Generate a Presigned PUT URL
Your backend (Node.js, Python, whatever you run) creates a presigned URL using the AWS SDK. The URL is scoped to a specific bucket, key, and content type, and it expires after a short window — 10 to 15 minutes is a good default. The client never sees your AWS credentials.
// Node.js example
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: "ap-south-1" });
export async function getUploadUrl(req, res) {
const key = `uploads/${Date.now()}-${req.body.fileName}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: req.body.contentType,
});
const url = await getSignedUrl(s3, command, { expiresIn: 900 });
res.json({ url, key });
}
The ContentType matters — it pins the object's type so the file is not served as application/octet-stream later. The expiresIn: 900 (15 minutes) is the security window: even if the URL leaks, it is useless after the deadline.
Step 3: The Flutter App — Request the URL, Then PUT
In Flutter, the flow is two requests. First, ask your backend for a presigned URL. Second, PUT the file bytes directly to that URL:
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
Future<http.Response> uploadToS3(
File file, {
required String fileName,
required String contentType,
required String apiBase,
}) async {
// 1. Ask your backend for a presigned PUT URL.
final signed = await http.post(
Uri.parse('$apiBase/api/s3/presign'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'fileName': fileName, 'contentType': contentType}),
);
final data = jsonDecode(signed.body) as Map<String, dynamic>;
final url = data['url'] as String;
// 2. PUT the bytes straight to S3.
return http.put(
Uri.parse(url),
headers: {'Content-Type': contentType},
body: file.readAsBytesSync(), // for web: bytes instead of File
);
}
Call it like this:
final res = await uploadToS3(
File('/path/to/receipt.jpg'),
fileName: 'receipt.jpg',
contentType: 'image/jpeg',
apiBase: 'https://your-api.com',
);
if (res.statusCode == 200) {
// Upload complete. Tell your backend the key so it can process the file.
}
A few things about this code:
- The PUT body is the raw file bytes. Do not wrap them in JSON; the presigned URL expects the raw binary content matching the
Content-Typeyou signed. - The response comes back as the object ETag on success (status 200). Your backend already knows the
keyfrom the presign step, so you do not need the app to return much. - The same function works for mobile and web. On web, there is no
Filewith a path — you passUint8Listbytes from afile_pickerresult instead. The upload call is identical.
Step 4: Web — Configure CORS on the Bucket
Here is the caveat I promised. On mobile, an S3 upload needs no special configuration. On the web, the browser blocks the PUT unless the bucket allows it. Your bucket's CORS policy needs to permit PUT from your app's origin:
[
{
"AllowedOrigins": ["https://your-app.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3000,
"ExposeHeaders": ["ETag"]
}
]
Set the AllowedOrigins to your actual app origin (and a localhost entry for development). AllowedHeaders must include Content-Type because you send it explicitly. If you forget this, the upload fails with a CORS error in the browser console — and it will fail silently for users, which is the worst kind of bug.
Important Notes and Pitfalls
-
Never put AWS credentials in the app. The presigned URL exists precisely so your secret never ships to a device or a browser. If I see an access key in a Flutter
const, I flag it in review, no exceptions. -
Keep the expiry short. Ten to fifteen minutes is plenty. A leaked presigned URL that lives for an hour is a credential; a leaked one that dies in fifteen minutes is an annoyance.
-
Content-Type must match. The
Content-Typein your PUT request must match the one you signed, or S3 rejects the request. Sign the type at presign time and send the identical header from the app. -
Large files and timeouts. For multi-hundred-MB files, a single
http.putcan exceed the default timeout. Add.timeout(const Duration(minutes: 5))or — better for huge files — switch to multipart upload, where you sign a separate presigned part and the app uploads pieces that S3 reassembles. Multipart is more code; add it only when single-shot PUT genuinely is not enough. -
Retry on 403 and 5xx. A 403 usually means the URL expired between request and upload (if your app waited too long), or the signed content type mismatched. Retry by requesting a fresh URL. A 5xx means S3 is having a moment — retry with backoff.
-
Tell the backend the upload finished. The presign step gives the backend the
key, but if your backend processes files on an S3 event (Lambda trigger), you are done. If not, send a small "upload complete" call with the key so your backend can verify the object exists. Do not trust the app's word alone — verify the object server-side. -
Progress reporting.
http.putdoes not give you upload progress out of the box. If you need a progress bar, switch todioand listen toonSendProgress. The flow is the same; the package just exposes the events. -
Don't sign for a user-provided
ContentTypeblindly. A malicious client could signtext/htmland host a page in your bucket. Whitelist allowed content types server-side, and serve your bucket through a private or correctly-configured public policy.
Large Files: The Multipart Path
For files in the hundreds of megabytes (video dumps, backup exports), a single PUT is the wrong tool — it needs a long-lived connection, retries the whole file on failure, and can hit proxies that time out. Multipart upload splits the file into 5–50 MB parts, uploads each in parallel with its own presigned URL, and lets S3 assemble the object. The app never signs anything; the backend does all the AWS work again.
The flow, simplified:
1. App → backend: "start multipart for file.bin, N parts"
2. Backend: s3.createMultipartUpload() → returns uploadId + presigned URLs for each part
3. App: PUT part 1..N (parallel, each to its own presigned URL)
4. App → backend: "complete multipart, uploadId, part ETags"
5. Backend: s3.completeMultipartUpload(...) → object is live
// Part upload — identical to the single PUT, just scoped per part.
Future<void> uploadPart(String presignedPartUrl, Uint8List bytes, String contentType) async {
final res = await http.put(
Uri.parse(presignedPartUrl),
headers: {'Content-Type': contentType},
body: bytes,
);
if (res.statusCode != 200) throw Exception('Part failed: ${res.statusCode}');
// Collect res.body (the ETag) and send it to the backend for completion.
}
The parts should be uploaded concurrently (three to five at a time is a sane default) and the ETags collected in order. On failure, you abort the multipart upload and restart — partial parts are garbage S3 will otherwise bill you for. Multipart is roughly double the code of the single-PUT path, which is exactly why I said earlier to add it only when a single PUT genuinely cannot handle your file size.
FAQ (the questions I actually get in comments)
-
"Do I need the AWS SDK in Flutter?" No. The app never touches AWS SDK or credentials. It only needs
httpto PUT to a URL. All AWS interaction happens server-side. -
"Does this work on iOS?" Yes — identical code. Mobile platforms need no bucket CORS configuration because there is no browser enforcing it.
-
"What about upload progress?" Use
dioinstead ofhttpand readonSendProgress. Everything else stays the same. -
"How do I protect files so users can't guess URLs?" Make objects private in the bucket and serve them through your backend (or CloudFront with signed URLs). The presigned-PUT pattern protects writes; protecting reads is a separate decision.
The Quick Checklist
- Presigned URL generated server-side with a short expiry (≤15 min).
- No AWS credentials anywhere in the Flutter app.
-
Content-Typesigned server-side and sent identically in the PUT. - CORS policy on the bucket for web builds.
-
ContentTypewhitelist server-side. - Timeout and retry (403 → fresh URL) handled.
- Backend verifies the object after upload.
That is the whole flow: presign, PUT, verify. It is faster than proxying through your backend, more secure than shipping a secret, and it behaves the same on Android, iOS, and web once the CORS rule is in place.
One more note from the trenches: when I need to spin up a quick demo uploader so a client can click through the presigned flow end-to-end before we build the real app, I use a prompt-to-website builder like misar.dev to generate the throwaway page in minutes instead of hand-writing an Express server. The real app is always Flutter; the demo just needs to exist long enough to prove the flow.
If your use case is different — multipart uploads, signed reads, upload progress bars, or integration with a specific backend — comment below with your scenario and I'll cover it next.
*Gulshan Yad
1. Secure Direct Uploads with Pre‑Signed URLs
When the client can write directly to S3, the entire upload path bypasses your backend, reducing server load and latency. The typical flow is: the Flutter app requests a pre‑signed URL from a short‑lived backend endpoint; the backend uses the AWS SDK to generate a URL that permits a single PUT or POST operation; the app then performs the HTTP request to that URL, optionally including a Content-Type header and any required form fields. The URL is time‑bound (often 5–15 minutes) and scoped to a single object key, so even if it leaks, it can’t be abused. In Flutter web, you can read the file with dart:html’s FileReader, convert it to a Uint8List, and send it with the http package’s put method. On mobile, use dio or http to stream the file directly to the pre‑signed URL, which keeps memory usage low.
Because the pre‑signed URL is generated on the server, you can enforce business rules before issuing it: check user quotas, validate file size, or attach metadata. You can also add custom headers that the client must send, ensuring the upload meets your expectations. After the upload succeeds, the client can immediately store the resulting S3 object key in your database, linking it to the user or a parent record.
If you need to support older browsers that don’t support fetch with a PUT request, you can use the POST form‑based pre‑signed URL, which accepts multipart form data. The Flutter web http package can still send a POST request with a FormData body that includes the file and the required fields.
2. CORS Configuration for Cross‑Domain Access
Cross‑Origin Resource Sharing (CORS) is mandatory for browser‑based uploads. Without a correct CORS policy, the browser will block the PUT request and report a 403 error. The policy is set at the bucket level and must allow the origin of your web app, the HTTP method (usually PUT or POST), and any custom headers you send (e.g., x-amz-acl). A minimal policy looks like:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAllWebOrigins",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::your-bucket/*",
"Condition": {
"StringEquals": {
"aws:Referer": ["https://your-app.com"]
}
}
}
]
}
After updating the CORS configuration, test the upload in a private window to confirm the browser no longer blocks it. If you still see errors, check the network tab for the OPTIONS preflight request and verify the Access-Control-Allow-Headers and Access-Control-Allow-Methods responses.
For mobile apps, CORS is irrelevant because the request originates from the device, not a browser. However, if you use a webview or hybrid approach, you still need to configure CORS.
3. Choosing the Right Flutter Package
The Flutter ecosystem offers several libraries for S3 integration:
aws_s3_client– a lightweight wrapper that uses thehttppackage under the hood. It supports both web and mobile, but you must manage pre‑signed URLs yourself.aws_s3_api– a full SDK generated from AWS’s API definitions. It includes higher‑level abstractions for multipart uploads and presigners, but it pulls in a large dependency tree.- Custom implementation – use the
httpordiopackage directly, generating pre‑signed URLs on your backend and handling the upload logic yourself.
For most production apps, the custom approach gives you the most control: you can embed retry logic, progress callbacks, and error handling exactly where you need it. A typical snippet for a web upload with a pre‑signed URL looks like:
final url = await fetchPresignedPutUrl();
final file = await FilePicker.platform.pickFiles();
final bytes = await File(file.paths.first).readAsBytes();
await http.put(Uri.parse(url), body: bytes, headers: {
'Content-Type': file.mimeType,
});
On mobile, using dio allows streaming:
await dio.put(url,
data: FormData.fromMap({'file': await MultipartFile.fromFile(file.path)}),
onSendProgress: (sent, total) {
setState(() => _progress = sent / total);
},
);
4. Chunked and Multipart Uploads for Large Files
S3 limits a single PUT request to 5 GB. For files larger than that, or for unreliable networks, multipart upload is the recommended strategy. The process is:
- Call
createMultipartUploadto get anuploadId. - Split the file into 5 MB or larger parts.
- Upload each part with
uploadPart, passing the part number and theuploadId. - After all parts are uploaded, call
completeMultipartUploadwith the list of part ETags.
In Flutter, you can read the file as a stream and write each chunk to a separate request. The dio package’s FormData can handle multipart forms, but for S3 you need to send each part as a PUT request to a pre‑signed URL that includes the part number. The backend can generate a separate pre‑signed URL for each part.
Handling failures is straightforward: if a part fails, retry only that part. If the entire upload fails, you can call abortMultipartUpload to clean up incomplete parts. Store the uploadId locally (e.g., in shared preferences) so you can resume if the app restarts.
5. File Selection and Permission Handling Across Platforms
On Flutter web, the file_picker package uses the browser’s native file picker. The user selects a file, and you receive a File object that you can read with FileReader. No extra permissions are required.
On Android and iOS, you also use file_picker or image_picker. For Android API 29+ you must request READ_EXTERNAL_STORAGE and, if you’re writing to the app’s cache, WRITE_EXTERNAL_STORAGE. For iOS, add the NSPhotoLibraryUsageDescription key to your Info.plist. The picker returns a File that you can stream directly to S3.
When you’re dealing with large files, avoid loading the entire file into memory. Instead, open a RandomAccessFile and read a fixed buffer size (e.g., 1 MB) in a loop, streaming each buffer to the upload endpoint. This keeps the app responsive and reduces the risk of an OutOfMemoryError on low‑end devices.
6. Performance, Cost, and Reliability Optimizations
Performance can be improved by enabling S3 Transfer Acceleration, which routes traffic through Amazon CloudFront’s edge network. If your users are worldwide, this can cut
Key Takeaways
- Use pre‑signed URLs so the Flutter app can write directly to S3 without routing traffic through your own server, keeping bandwidth and latency low.
- Configure a CORS policy that allows GET, PUT, POST, and HEAD from your web domain and mobile app origins; without it the browser will block the upload.
- On Flutter web read the file with
dart:html’sFileReaderand send it with thehttppackage; on mobile useimage_pickerorfile_pickerand stream the file withdioorhttp. - For files larger than 5 MB, switch to multipart upload to avoid timeouts and reduce memory usage; keep track of part numbers and upload IDs.
- Show upload progress in the UI by listening to the
onSendProgresscallback of the HTTP client and update a progress bar or spinner. - Persist the S3 object key and any relevant metadata in your own database so you can reference, delete, or share the file later.
Frequently Asked Questions
How do I generate a pre‑signed URL for uploading?
Create a small backend endpoint that uses the AWS SDK to call createPresignedPost or createPresignedRequest for the desired bucket, key, and expiration. Return the URL and any required fields to the Flutter app.
What CORS headers must I include for a Flutter web upload?
At minimum, allow the PUT method and the Content-Type header. A typical bucket policy looks like: AllowedMethods: ['PUT', 'POST', 'GET', 'HEAD'], AllowedHeaders: ['*'], AllowedOrigins: ['https://your‑app.com'].
Can I upload directly from Flutter mobile without a backend?
Yes, if you embed IAM credentials in the app you can use the AWS SDK for Dart to call putObject. However, this exposes credentials to the device; the recommended approach is to use a short‑lived token or pre‑signed URL issued by a secure backend.
How do I handle large files on mobile?
Use a multipart upload: first call createMultipartUpload to get an upload ID, then upload each part with uploadPart, and finally call completeMultipartUpload. This allows you to retry individual parts if a network hiccup occurs.
What happens if the upload fails mid‑stream?
If you’re using a pre‑signed URL, the request will fail and you can retry the entire upload or, if you used multipart, you can resume missing parts. For resumable uploads you’ll need to store the upload ID locally.
Do I need to enable Transfer Acceleration for faster uploads?
Transfer Acceleration is useful if your users are globally distributed and the S3 bucket is in a region far from them. Enabling it adds a small cost but can cut upload times from several seconds to fractions of a second.
How do I keep the UI responsive during a large upload?
Stream the file in chunks and update the progress bar in the onSendProgress callback. Avoid reading the entire file into memory; on mobile use dio’s FormData with a file stream.
Can I encrypt the file before uploading?
Yes, encrypt the file locally with a symmetric key and upload the ciphertext. Store the key securely (e.g., in AWS KMS or the device’s secure storage) so you can decrypt it later.
What is the cost impact of multipart uploads?
Multipart uploads incur a small per‑part request cost and storage for each part until the upload is completed. Keep parts reasonably sized (e.g., 5 MB) to balance the number of requests and the risk of timeouts.
How do I delete a file from S3 after it’s uploaded?
Call the S3 deleteObject API with the bucket name and key. If you stored the key in your database, you can delete the record and the file in a single transaction.
1 followers
AI systems builder · 7 years in production. RAG, self-hosted infra, agent architecture. 📬 Deep-dives → mrgulshanyadav.substack.com






Comments
Sign in to join the conversation
No comments yet. Be the first to share your thoughts!