Connect FlutterFlow to AWS S3 for file uploads using a presigned URL pattern: a Firebase Cloud Function generates a short-lived presigned PUT URL using the AWS SDK (with your IAM secret stored server-side), returns it to the FlutterFlow app, and the app uploads the file directly to S3 using that URL. AWS credentials never touch the device. Configure bucket CORS for web builds.
| Fact | Value |
|---|---|
| Tool | AWS S3 |
| Category | Database & Backend |
| Method | FlutterFlow API Call |
| Difficulty | Intermediate |
| Time required | 60 minutes |
| Last updated | July 2026 |
The Presigned URL Pattern: Your IAM Secret Never Leaves the Server
Every request to AWS S3 must be signed with SigV4, a cryptographic signature scheme that uses your IAM access key ID and secret access key. If you put those credentials into a FlutterFlow Custom Action or an API Call header, they get compiled into the Flutter app binary — and anyone who downloads your APK or IPA can extract them. A leaked IAM secret can be used to upload unlimited files to your S3 bucket at your expense, or worse, delete everything in it.
The solution is the presigned URL pattern. Your AWS IAM secret never goes near FlutterFlow. Instead, a Firebase Cloud Function holds the secret and uses the AWS SDK to generate a time-limited presigned URL — a special S3 URL that already has the SigV4 signature baked in, valid only for a short window (typically 15 minutes). The Cloud Function returns just the URL to the FlutterFlow app. FlutterFlow then makes a simple unauthenticated HTTP PUT to that URL with the file bytes. S3 validates the signature in the URL itself. No credentials touched the device.
AWS S3's free tier covers 5GB of storage, 20,000 GET requests, and 2,000 PUT requests per month for the first 12 months. After that, storage costs roughly $0.023 per GB per month and requests are fractions of a cent — check current pricing at aws.amazon.com/s3/pricing. The bucket needs CORS configuration for web-build uploads, because the browser enforces cross-origin rules even on presigned PUT requests. Native iOS and Android builds are not subject to CORS, but it is good practice to configure CORS anyway so your web build works identically.
Integration method
S3 requests must be SigV4-signed with an IAM secret — signing cannot happen in a Flutter client without exposing the secret. The FlutterFlow pattern uses a Firebase Cloud Function to generate a short-lived presigned PUT URL, returns it to the app, and FlutterFlow performs a direct unauthenticated PUT of the file bytes straight to S3. A second presigned GET URL is generated for retrieving or displaying files. AWS credentials stay exclusively in the Cloud Function environment.
Prerequisites
- An AWS account with billing set up (S3 is in the free tier for 12 months; credit card required to activate)
- An S3 bucket created in your target region with appropriate access settings
- An IAM user with `s3:PutObject` and `s3:GetObject` permissions scoped to your bucket — never use root credentials
- A Firebase project with Blaze (pay-as-you-go) plan for Cloud Functions deployment
- A FlutterFlow project — any plan works for the API Call portion; Pro plan required for Custom Actions if you need raw binary upload handling
Step-by-step guide
Create an S3 bucket and configure CORS
Sign in to the AWS console at console.aws.amazon.com and navigate to S3. Click 'Create bucket'. Choose a globally unique bucket name (e.g. `myapp-user-uploads`). Select the AWS Region closest to most of your users — this affects upload and download latency. Under 'Block Public Access settings', keep all four checkboxes checked if your files should be private (accessed only via presigned URLs). If you want some files publicly readable (like profile photos you serve via CloudFront), you can adjust this later per-prefix. After creating the bucket, click it, go to the 'Permissions' tab, and scroll to 'Cross-origin resource sharing (CORS)'. Click Edit and paste the following CORS configuration. This allows the FlutterFlow web build to PUT files directly to S3 from the browser: Save the CORS config. Without this, browser-based uploads (FlutterFlow web builds) will fail silently or with a CORS error in the browser console — but native iOS/Android builds will work fine because they don't enforce CORS. Configuring CORS now ensures your web and mobile builds behave identically. Create an IAM user next. In the AWS console, go to IAM → Users → Create user. Give it a service-account-style name like `flutterflow-s3-uploader`. Attach a custom inline policy that allows only `s3:PutObject` and `s3:GetObject` on your specific bucket ARN (e.g. `arn:aws:s3:::myapp-user-uploads/*`). Never attach AdministratorAccess or full S3 access to this user. Download the access key ID and secret — you will put these in Firebase Function config in the next step.
1[2 {3 "AllowedHeaders": ["*"],4 "AllowedMethods": ["PUT", "GET"],5 "AllowedOrigins": ["*"],6 "ExposeHeaders": ["ETag"]7 }8]Pro tip: For production, replace the wildcard AllowedOrigins with your actual app domain (e.g. `https://myapp.web.app`) to restrict cross-origin PUT access to your own web app only.
Expected result: Your S3 bucket exists with CORS configured. An IAM user with minimal S3 permissions has been created and you have the access key ID and secret saved securely.
Write a Firebase Cloud Function to generate presigned URLs
This Cloud Function is the heart of the integration. It uses the AWS SDK for JavaScript v3 (`@aws-sdk/s3-request-presigner` and `@aws-sdk/client-s3`) to generate a short-lived presigned URL. The function accepts a POST request with `fileName` and `contentType` in the body, constructs the S3 object key (e.g. `uploads/{userId}/{fileName}`), calls `getSignedUrl()` with a `PutObjectCommand`, and returns the presigned URL to FlutterFlow. The AWS access key ID and secret are stored in Firebase Function config — they never appear in source code. Set them with the Firebase CLI: `firebase functions:config:set aws.access_key_id="AKIA..." aws.secret_access_key="xxx" aws.region="us-east-1" aws.bucket="myapp-user-uploads"`. The presigned URL is valid for the duration you specify — 900 seconds (15 minutes) is a good default. Generate the presigned URL right when the user is about to upload and use it immediately. Do not cache presigned URLs in App State across sessions; they expire and become invalid. You need a Firebase Blaze (pay-as-you-go) plan to deploy Cloud Functions. The function itself is lightweight and typically falls within the free tier of 2 million invocations per month.
1// Firebase Cloud Function — S3 presigned URL generator (index.js)2const functions = require('firebase-functions');3const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');4const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');56const s3 = new S3Client({7 region: functions.config().aws.region,8 credentials: {9 accessKeyId: functions.config().aws.access_key_id,10 secretAccessKey: functions.config().aws.secret_access_key,11 },12});1314exports.getPresignedUploadUrl = functions.https.onRequest(async (req, res) => {15 res.set('Access-Control-Allow-Origin', '*');16 if (req.method === 'OPTIONS') {17 res.set('Access-Control-Allow-Methods', 'POST');18 res.set('Access-Control-Allow-Headers', 'Content-Type');19 return res.status(204).send('');20 }2122 const { fileName, contentType, userId } = req.body;23 if (!fileName || !contentType) {24 return res.status(400).json({ error: 'fileName and contentType are required' });25 }2627 const key = `uploads/${userId || 'anonymous'}/${Date.now()}_${fileName}`;28 const bucket = functions.config().aws.bucket;2930 try {31 const putUrl = await getSignedUrl(32 s3,33 new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }),34 { expiresIn: 900 } // 15 minutes35 );36 const getUrl = await getSignedUrl(37 s3,38 new GetObjectCommand({ Bucket: bucket, Key: key }),39 { expiresIn: 86400 } // 24 hours for viewing40 );41 return res.status(200).json({ putUrl, getUrl, key });42 } catch (err) {43 console.error('S3 presign error:', err);44 return res.status(500).json({ error: err.message });45 }46});Pro tip: Return both the PUT URL (for uploading) and a GET URL (for display) in the same response — this saves a second round-trip and lets FlutterFlow show the uploaded file immediately after upload.
Expected result: The Cloud Function is deployed. A test POST with `{"fileName":"test.jpg","contentType":"image/jpeg","userId":"user123"}` returns `{"putUrl":"https://...","getUrl":"https://...","key":"uploads/user123/..."}` with S3 presigned URLs.
Build the FlutterFlow API Groups for the upload flow
The upload flow needs two API Calls in sequence: one to get the presigned URLs from your Cloud Function, and one to actually PUT the file to S3 using that URL. In FlutterFlow, click 'API Calls' in the left navigation, click '+ Add' → 'Create API Group', and name it 'S3Upload'. First API Call — 'GetPresignedUrl': Method POST, URL = your Cloud Function URL (e.g. `https://us-central1-yourproject.cloudfunctions.net/getPresignedUploadUrl`). Add a JSON Body with variables for `fileName` (String), `contentType` (String), and `userId` (String). In the Response & Test tab, parse `$.putUrl` and `$.getUrl` and `$.key` as JSON Paths. Second API Call — 'UploadToS3': Method PUT, URL = `{{presignedPutUrl}}` where `presignedPutUrl` is a variable. Add a Header `Content-Type: {{contentType}}` matching the content type you signed. The body is the raw file bytes — this is where FlutterFlow's API Call handling has a limitation: API Calls in FlutterFlow are designed for JSON bodies, not raw binary data. For simple image uploads in many cases you can encode the image as a base64 data URI and handle it server-side, but for true binary PUT to S3, a Custom Action (Dart) using the `http` package with `put()` and binary bytes is more reliable. If your app targets mobile only, this Custom Action approach works well. For a web-compatible solution, route the binary upload through a Cloud Function that accepts a base64 body and streams it to S3 using the S3 SDK's `Body` parameter.
1{2 "step1_get_presigned_url": {3 "method": "POST",4 "url": "https://us-central1-yourproject.cloudfunctions.net/getPresignedUploadUrl",5 "body": {6 "fileName": "{{fileName}}",7 "contentType": "{{contentType}}",8 "userId": "{{userId}}"9 }10 },11 "step2_put_to_s3": {12 "method": "PUT",13 "url": "{{presignedPutUrl}}",14 "headers": {15 "Content-Type": "{{contentType}}"16 },17 "body": "<raw file bytes>"18 }19}Pro tip: Use the Action Flow Editor's sequential action blocks: first 'Backend Call → GetPresignedUrl', store the response URL in a Page State variable, then 'Backend Call → UploadToS3' using that stored URL.
Expected result: Two API Calls exist in the S3Upload group — GetPresignedUrl returning the S3 URLs, and UploadToS3 configured to PUT to the presigned URL variable.
Wire the upload flow to an image picker or file picker widget
The complete upload action flow starts when a user selects a file. In FlutterFlow, add an 'Upload/Save Media' action or a 'Store Media' action (depending on your FlutterFlow version) to an image picker button or tap gesture. This stores the selected file in a variable (as bytes or a local path, depending on the platform). In the Action Flow Editor, after the media picker action, add a 'Backend/API Call' action targeting 'S3Upload → GetPresignedUrl'. Pass the file name (from the picker result) and the appropriate content type (`image/jpeg`, `image/png`, `application/pdf`, etc.) as variables. Store the returned `putUrl` and `getUrl` in Page State variables. Next in the Action Flow, add a second API Call action for 'S3Upload → UploadToS3', passing the stored `putUrl` as the URL variable and the file bytes as the body. If the FlutterFlow API Call body cannot handle raw bytes for your file type, you'll need to use a Custom Action here — Dart's `http.put(Uri.parse(putUrl), body: fileBytes, headers: {'Content-Type': contentType})` handles this cleanly. After the successful upload, store the `getUrl` in your backend database (Firestore document, Supabase row) so it can be retrieved later. Display the uploaded file immediately in the UI by binding an Image widget to the Page State `getUrl` variable. Show a loading indicator during the upload process using a Boolean Page State variable toggled before and after the API Call sequence.
1// Custom Action for binary S3 PUT upload (custom_action.dart)2import 'dart:typed_data';3import 'package:http/http.dart' as http;45Future<bool> uploadToS3(6 String presignedUrl,7 Uint8List fileBytes,8 String contentType,9) async {10 try {11 final response = await http.put(12 Uri.parse(presignedUrl),13 headers: {'Content-Type': contentType},14 body: fileBytes,15 );16 return response.statusCode == 200;17 } catch (e) {18 return false;19 }20}Pro tip: Always check the S3 PUT response status code — a successful upload returns 200 with an empty body. A 403 means the Content-Type header doesn't match what you signed, or the presigned URL has expired.
Expected result: Selecting an image and tapping Upload calls the Cloud Function, gets the presigned URL, uploads the file to S3, and shows the uploaded image in the UI via the presigned GET URL.
Store file URLs and handle retrieval
After a successful upload, you need to store the file's location so it can be retrieved later. The `key` returned by your Cloud Function (e.g. `uploads/user123/1234567890_photo.jpg`) is the stable identifier for the object. The presigned GET URL expires (24 hours in the example Cloud Function), so you should not store the presigned URL itself — store the object key instead. In Firestore or Supabase, save the S3 object key to the user's profile document or the relevant record (e.g. `avatar_s3_key: 'uploads/user123/1234567890_photo.jpg'`). When you need to display the file, make a fresh call to your Cloud Function to generate a new presigned GET URL from the stored key. Add a second endpoint to the Cloud Function (e.g. `getPresignedDownloadUrl`) that accepts a `key` parameter and returns a fresh GET URL. Alternatively, if you want publicly accessible files (like profile photos that can be shown to anyone), you can configure a CloudFront distribution in front of your S3 bucket. CloudFront provides a stable CDN URL (`https://xxxx.cloudfront.net/uploads/user123/photo.jpg`) that doesn't expire. Store the CloudFront URL instead of the S3 presigned URL — it works indefinitely without regeneration. CloudFront is outside the scope of this guide but is the recommended pattern for any file that needs to be publicly viewable. If you'd rather skip the backend setup entirely, RapidDev's team builds FlutterFlow integrations like this every week — free scoping call at rapidevelopers.com/contact.
Pro tip: Save the S3 object `key`, not the presigned URL, to your database. Presigned URLs expire; the key is permanent and you can generate a fresh URL from it any time.
Expected result: Uploaded files are retrievable by storing the S3 key in your database and generating fresh presigned GET URLs on demand. Profile photos or documents appear correctly in the app.
Common use cases
Profile photo upload with S3 storage and CloudFront CDN delivery
A social networking FlutterFlow app lets users update their profile photo. Tapping the avatar opens an image picker, the selected image is sent to a Cloud Function that returns a presigned PUT URL for a path like `avatars/{userId}.jpg`, and FlutterFlow uploads the image directly to S3. After upload, the app stores the public CloudFront URL in the user's Firestore document and displays it via an image widget. CloudFront caches the image at edge nodes for fast global delivery.
Build a profile screen with an editable avatar. On tap, open the image picker. After selection, call a Cloud Function to get a presigned PUT URL, upload the image, then save the returned CloudFront URL to the user's Firestore profile and update the avatar widget.
Copy this prompt to try it in FlutterFlow
Document upload app for contract or invoice storage
A B2B app lets clients upload contracts, invoices, or signed documents directly from their phone. The app calls a Cloud Function with the file name and content type, gets a presigned PUT URL for `documents/{clientId}/{filename}`, and uploads the file bytes. The Cloud Function also returns a presigned GET URL with a 7-day expiry for the client to view their uploaded document later. All documents are private — no public bucket policy.
Add a 'Upload Document' button to the client profile screen. After picking a PDF or image file, call the Cloud Function to get upload and download presigned URLs. Upload the file, then save the download URL to the client record in Supabase so the team can access it.
Copy this prompt to try it in FlutterFlow
Audio recording app that saves voice notes to S3
A field notes app lets workers record voice notes and save them to S3 for later review. A Custom Action uses the `record` pub.dev package to capture audio to a local temp file. After recording, the app calls the presigned URL Cloud Function with `audio/m4a` as the content type, then uploads the file bytes using a second API Call. The audio URL is stored per note in the backend database.
Create a voice note screen with a Record and Stop button. After stopping, upload the audio file to S3 via the presigned URL pattern and save the returned file URL to a Firestore 'notes' document along with the timestamp and user ID.
Copy this prompt to try it in FlutterFlow
Troubleshooting
S3 PUT upload returns 403 Forbidden with 'SignatureDoesNotMatch'
Cause: The `Content-Type` header in the FlutterFlow PUT call does not match the Content-Type that was signed into the presigned URL when it was generated.
Solution: Ensure the `contentType` variable passed to the Cloud Function when generating the presigned URL exactly matches the `Content-Type` header you set in the FlutterFlow PUT API Call. Both must be identical — if you signed `image/jpeg` but send `image/jpg` or omit the header entirely, S3 rejects the request with 403.
Upload works on mobile (iOS/Android) but fails on the web build with 'XMLHttpRequest error' or a CORS error in browser DevTools
Cause: The S3 bucket does not have CORS configured, or the CORS configuration does not allow PUT from the app's web origin.
Solution: Go to the S3 bucket in the AWS console → Permissions → CORS. Add a CORS rule allowing `PUT` and `GET` from `*` (or your specific web app origin). Save the CORS configuration, wait 30 seconds for it to propagate, and retry the upload from the web build.
Cloud Function returns the presigned URL but the PUT call immediately fails with 403 — 'Request has expired'
Cause: There is a significant clock skew between the FlutterFlow app's device clock and AWS servers, or the presigned URL was cached and has expired before use.
Solution: Generate the presigned URL immediately before the upload action, not in advance. Do not store presigned URLs across app sessions or in persistent App State. If the device clock is significantly off (>15 minutes), AWS will reject the signature. The fix is to generate the URL as close to the upload action as possible — in the same Action Flow sequence, not on an earlier screen.
The IAM user's access key was accidentally committed to source code or visible in the FlutterFlow API Call headers
Cause: The AWS credentials were placed in the wrong location — they were put in FlutterFlow instead of in Firebase Function config.
Solution: Immediately go to AWS IAM → Users → your IAM user → Security credentials → deactivate or delete the compromised access key. Create a new access key and store it only in Firebase Function config (`firebase functions:config:set aws.access_key_id=... aws.secret_access_key=...`). Rotate the key in the Cloud Function and redeploy. Enable AWS CloudTrail to audit whether the compromised key was used by anyone else.
Best practices
- Never put AWS IAM access keys in FlutterFlow — they must live only in Firebase Function config (firebase functions:config:set) or AWS Secrets Manager, never in API Call headers or Custom Action code.
- Generate presigned PUT URLs immediately before uploading — do not cache them in App State across screens or sessions, as they expire (typically within 15 minutes).
- Configure S3 bucket CORS from day one even if you are initially building only for mobile, so the web build works without changes when you add web support later.
- Create an IAM user scoped to only `s3:PutObject` and `s3:GetObject` on your specific bucket ARN — never use root credentials or attach AdministratorAccess to a service account.
- Store the S3 object key (e.g. `uploads/user123/photo.jpg`) in your database rather than the presigned URL — the key is permanent while presigned URLs expire.
- Match the Content-Type you sign into the presigned URL exactly with the Content-Type header in the PUT call — a mismatch causes 403 SignatureDoesNotMatch errors.
- Use CloudFront in front of your S3 bucket for any files that need to be publicly viewable — this provides a stable CDN URL, improves download speed globally, and lets you add signed cookie authentication if needed later.
- Set up AWS S3 bucket versioning and lifecycle rules from the start — versioning protects against accidental overwrites and lifecycle rules automatically transition old files to cheaper storage tiers.
Alternatives
Firebase Storage is natively integrated into FlutterFlow and handles uploads without any Cloud Function proxy — choose it over S3 when you are already using Firebase and want the simplest possible file upload experience.
Cloudflare R2 is S3-compatible object storage with zero egress fees — a cost-effective alternative to S3 when download bandwidth costs are a concern for your app's file serving volume.
If your use-case is sending files as email attachments rather than storing them, AWS SES handles transactional email with attachments through the same Cloud Function proxy pattern.
Frequently asked questions
Why can't I just put my AWS access key in a FlutterFlow API Call header?
FlutterFlow compiles to a Flutter app that runs on users' devices. Any value you put in an API Call header, App Constant, or Custom Action code is compiled into the app binary. Anyone who downloads your APK or IPA and runs a decompiler can extract that key in minutes. A leaked IAM key allows the holder to upload files to your bucket, delete your data, and incur charges on your AWS account. The presigned URL pattern keeps your credentials entirely server-side.
Do presigned S3 URLs work for displaying images in FlutterFlow Image widgets?
Yes — an Image widget's URL field accepts a presigned GET URL and displays the image normally. However, presigned GET URLs expire (in our example, after 24 hours). For images that need to be visible indefinitely (like profile photos), either regenerate the URL on each screen load from the stored key, or serve the files via CloudFront with a stable non-expiring URL.
Can I upload files larger than a few MB from FlutterFlow?
Presigned PUT URLs support files up to 5GB in a single PUT request. The practical limit depends on the user's network speed and the device memory available to hold the file bytes before upload. For very large files (video recordings, large documents), consider S3 Multipart Upload — this requires a more complex Cloud Function setup but handles files of any size reliably on slow connections.
How do I make uploaded files publicly accessible without needing a presigned URL every time?
In the S3 bucket settings, you can disable 'Block all public access' and add a bucket policy granting `s3:GetObject` to `*` for a specific prefix (e.g. `public/*`). Files uploaded to the `public/` prefix can then be accessed at their direct S3 URL (`https://bucket.s3.region.amazonaws.com/public/filename`) without signing. Be careful: this makes those files downloadable by anyone with the URL. For better CDN delivery and access control, use CloudFront in front of the bucket.
What is the cost of using S3 for user file uploads in a production app?
AWS S3 charges for storage (roughly $0.023/GB/month for standard storage), PUT requests (~$0.005 per 1,000), and GET requests (~$0.0004 per 1,000). Data transfer out of S3 to the internet incurs egress fees (~$0.09/GB). For most apps with moderate file volumes, monthly costs are under $5. If egress costs become significant, Cloudflare R2 offers S3-compatible storage with zero egress fees. Check current AWS pricing at aws.amazon.com/s3/pricing.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation