DEV Community

Cover image for Upload Files to AWS S3 from Flutter (Web + Mobile)
Gulshan Yadav
Gulshan Yadav

Posted on Originally published at misar.blog

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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-Type you signed.
  • The response comes back as the object ETag on success (status 200). Your backend already knows the key from 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 File with a path — you pass Uint8List bytes from a file_picker result 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"]
  }
]
Enter fullscreen mode Exit fullscreen mode

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

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

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

  3. Content-Type must match. The Content-Type in 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.

  4. Large files and timeouts. For multi-hundred-MB files, a single http.put can 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.

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

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

  7. Progress reporting. http.put does not give you upload progress out of the box. If you need a progress bar, switch to dio and listen to onSendProgress. The flow is the same; the package just exposes the events.

  8. Don't sign for a user-provided ContentType blindly. A malicious client could sign text/html and 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
Enter fullscreen mode Exit fullscreen mode
// 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.
}
Enter fullscreen mode Exit fullscreen mode

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 http to 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 dio instead of http and read onSendProgress. 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-Type signed server-side and sent identically in the PUT.
  • [ ] CORS policy on the bucket for web builds.
  • [ ] ContentType whitelist 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

Top comments (0)