Bubble cannot sign AWS S3 requests client-side — your IAM secret must stay server-side. The correct pattern is a Backend Workflow that calls a small Lambda or API Gateway endpoint to generate a presigned PUT URL, then the browser uploads the file directly to S3 using that URL. Requires a Bubble paid plan (Starter+) and S3 bucket CORS configuration.
| Fact | Value |
|---|---|
| Tool | AWS S3 |
| Category | Database & Backend |
| Method | Bubble API Connector |
| Difficulty | Advanced |
| Time required | 2–3 hours |
| Last updated | July 2026 |
Bubble + AWS S3: Server-Side Signing Is Non-Negotiable
When founders first try to connect Bubble to their own S3 bucket, they often try to call S3 directly from a Bubble workflow — and hit a wall. The reason: S3 API calls require AWS SigV4 signing, which uses your IAM Access Key Secret as part of the signature algorithm. Putting that secret anywhere accessible to the browser would expose it to any user who opens DevTools. Bubble's API Connector private headers protect secrets in transit, but the SigV4 algorithm itself has to run somewhere with code execution capability — which Bubble's visual workflows don't provide.
The correct pattern separates the problem into three stages. First, a small server-side function (AWS Lambda is the natural fit — it's already in the AWS ecosystem) receives a filename and content type, computes a presigned PUT URL using the AWS SDK, and returns that URL to the caller. This function holds your IAM credentials in its own environment variables, never in Bubble. Second, Bubble's Backend Workflow calls this Lambda via the API Connector, gets the presigned URL back, and writes it to an App State variable. Third, a client-side Bubble workflow reads the presigned URL from App State and sends the file bytes via a direct HTTP PUT — this PUT goes straight from the user's browser to S3, bypassing Bubble entirely.
This approach is WU-efficient: only one Backend Workflow call (the Lambda invocation) consumes server-side WUs. The actual file transfer is browser-to-S3 and does not count as a Bubble operation. It also scales cleanly — S3 has no meaningful rate limit on PUTs across different prefixes, and Lambda scales automatically.
Important caveat: Bubble Backend Workflows are a paid-plan feature (Starter and above). If you are on Bubble's free plan, you cannot implement this pattern server-side and should not attempt to work around it by putting IAM credentials in client-side actions.
Integration method
Bubble API Connector calls a Lambda/API Gateway endpoint that generates AWS SigV4-signed presigned URLs; file bytes travel directly from the browser to S3 without passing through Bubble's servers.
Prerequisites
- A Bubble app on a paid plan (Starter or above) — Backend Workflows are not available on the free plan
- An AWS account with permissions to create IAM users, S3 buckets, Lambda functions, and API Gateway endpoints
- An S3 bucket created in the AWS region closest to your users
- Basic familiarity with the AWS Console (IAM, Lambda, and S3 sections)
- The Bubble API Connector plugin installed in your app (Plugins tab → Add plugins → search 'API Connector' by Bubble)
Step-by-step guide
Create an IAM user scoped to your S3 bucket
In the AWS Console, go to IAM → Users → Create user. Give it a name like `bubble-s3-uploader`. On the permissions step, choose 'Attach policies directly' → Create policy (JSON tab). Paste a policy that grants only `s3:PutObject` and `s3:GetObject` on your specific bucket ARN: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject"], "Resource": "arn:aws:s3:::your-bucket-name/*" } ] } ``` Name the policy `BubbleS3UploadPolicy` and create it. Attach it to the new IAM user. After creating the user, go to the user detail page → Security credentials tab → Create access key. Choose 'Application running outside AWS' and click Create. Download the credentials CSV — you will not be able to see the Secret Access Key again after closing this dialog. This user has the minimum possible permissions: it can only put and get objects in your specific bucket. It cannot list bucket contents, delete files, or access any other AWS service. This principle of least privilege limits the blast radius if the key were ever exposed.
1{2 "Version": "2012-10-17",3 "Statement": [4 {5 "Effect": "Allow",6 "Action": ["s3:PutObject", "s3:GetObject"],7 "Resource": "arn:aws:s3:::your-bucket-name/*"8 }9 ]10}Pro tip: Never grant s3:DeleteObject or s3:ListBucket in this policy unless your app specifically needs those operations. Keeping the policy tight means a compromised key cannot be used to enumerate or delete your files.
Expected result: You have an IAM Access Key ID (starts with `AKIA`) and Secret Access Key saved locally. The IAM user has no AWS Console access and no permissions outside your specified bucket.
Configure S3 bucket CORS for your Bubble domain
In the AWS Console, open your S3 bucket → Permissions tab → CORS configuration. Click Edit and paste the following CORS rule, replacing the AllowedOrigins value with your actual Bubble app domain: ```json [ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET"], "AllowedOrigins": [ "https://yourapp.bubbleapps.io", "https://www.yourdomain.com" ], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ] ``` This CORS configuration is critical. Without it, the browser's Same-Origin Policy will block the direct PUT from your Bubble app to S3 — even though the presigned URL is technically valid. The browser sends a preflight OPTIONS request to S3 before the actual PUT; S3 uses this CORS rule to decide whether to allow the cross-origin request. Include your full Bubble domain (e.g. `yourapp.bubbleapps.io`) and any custom domain you've connected. Save the CORS configuration. The change takes effect immediately — you do not need to redeploy or restart anything in AWS.
1[2 {3 "AllowedHeaders": ["*"],4 "AllowedMethods": ["PUT", "GET"],5 "AllowedOrigins": [6 "https://yourapp.bubbleapps.io",7 "https://www.yourdomain.com"8 ],9 "ExposeHeaders": ["ETag"],10 "MaxAgeSeconds": 300011 }12]Pro tip: Test CORS before wiring Bubble. Upload a file to S3 manually, then try a simple fetch('https://your-bucket.s3.amazonaws.com/test.txt') from your browser's DevTools console on your Bubble app's domain. If it returns a CORS error, the rule isn't applied yet.
Expected result: S3 CORS configuration saved. The bucket now accepts PUT and GET requests originating from your Bubble app's domain.
Deploy a Lambda function to generate presigned URLs
In the AWS Console, go to Lambda → Create function → Author from scratch. Name it `generate-s3-presigned-url`, runtime Node.js 20.x. Under Configuration → Environment variables, add: `AWS_S3_BUCKET` = `your-bucket-name`, `AWS_S3_REGION` = `us-east-1` (your bucket's region). For Lambda's execution role, attach the same `BubbleS3UploadPolicy` you created in Step 1 (Lambda will use its own role credentials, not the IAM user's keys — which is even better security practice). In the Lambda code editor, paste the following function: ```javascript import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { randomUUID } from 'crypto'; const s3 = new S3Client({ region: process.env.AWS_S3_REGION }); export const handler = async (event) => { const body = JSON.parse(event.body || '{}'); const { fileName, contentType, folder = 'uploads' } = body; if (!fileName || !contentType) { return { statusCode: 400, body: JSON.stringify({ error: 'fileName and contentType required' }) }; } const key = `${folder}/${randomUUID()}/${fileName}`; const putCommand = new PutObjectCommand({ Bucket: process.env.AWS_S3_BUCKET, Key: key, ContentType: contentType }); const getCommand = new GetObjectCommand({ Bucket: process.env.AWS_S3_BUCKET, Key: key }); const putUrl = await getSignedUrl(s3, putCommand, { expiresIn: 900 }); const getUrl = await getSignedUrl(s3, getCommand, { expiresIn: 86400 }); return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ putUrl, getUrl, key }) }; }; ``` Deploy the function. Then add an API Gateway trigger: under Function overview → Add trigger → API Gateway → Create a new REST API → Open (for now, you'll add an API key later). After saving, note the API endpoint URL — this is what Bubble will call. If you want auth on the API Gateway endpoint, add an API key or use IAM authorization — this prevents unauthorized calls to your Lambda. Note: RapidDev's team has built hundreds of Bubble apps with integrations like this, including the Lambda + Bubble Backend Workflow pattern for S3. If the serverless setup feels overwhelming, book a free scoping call at rapidevelopers.com/contact.
1import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';2import { getSignedUrl } from '@aws-sdk/s3-request-presigner';3import { randomUUID } from 'crypto';45const s3 = new S3Client({ region: process.env.AWS_S3_REGION });67export const handler = async (event) => {8 const body = JSON.parse(event.body || '{}');9 const { fileName, contentType, folder = 'uploads' } = body;1011 if (!fileName || !contentType) {12 return { statusCode: 400, body: JSON.stringify({ error: 'fileName and contentType required' }) };13 }1415 const key = `${folder}/${randomUUID()}/${fileName}`;1617 const putCommand = new PutObjectCommand({18 Bucket: process.env.AWS_S3_BUCKET,19 Key: key,20 ContentType: contentType21 });2223 const getCommand = new GetObjectCommand({24 Bucket: process.env.AWS_S3_BUCKET,25 Key: key26 });2728 const putUrl = await getSignedUrl(s3, putCommand, { expiresIn: 900 });29 const getUrl = await getSignedUrl(s3, getCommand, { expiresIn: 86400 });3031 return {32 statusCode: 200,33 headers: { 'Content-Type': 'application/json' },34 body: JSON.stringify({ putUrl, getUrl, key })35 };36};Pro tip: The ContentType signed into the presigned URL must exactly match the Content-Type header you send in the browser PUT request. If they differ, S3 returns a SignatureDoesNotMatch error. Bubble passes the file's MIME type — capture it from the File Uploader element's 'file type' output and pass it to the Backend Workflow.
Expected result: Lambda function deployed and accessible via API Gateway URL. Calling it with POST body {"fileName": "test.jpg", "contentType": "image/jpeg"} returns a JSON object containing putUrl, getUrl, and key.
Configure the Bubble API Connector to call your Lambda
In your Bubble app, go to Plugins tab → Add plugins → search 'API Connector' → Install the official plugin by Bubble. Once installed, click 'API Connector' in the left panel to open it. Click 'Add another API' and name it 'AWS Lambda S3'. Set the shared authentication to 'None' for now (security is handled by API Gateway). If you added an API key to your API Gateway endpoint, add a shared header: - Header name: `x-api-key` - Value: `your-api-gateway-key` - Check the **Private** checkbox Click 'Add another call'. Configure the call: - Name: `Get Presigned URL` - Method: POST - URL: `https://your-api-gateway-id.execute-api.us-east-1.amazonaws.com/default/generate-s3-presigned-url` - Add body parameter (JSON): set Body type to 'JSON' - Body fields: `fileName` (text, dynamic), `contentType` (text, dynamic), `folder` (text, value 'uploads') The call configuration looks like: ```json { "method": "POST", "url": "https://your-api-gateway-id.execute-api.us-east-1.amazonaws.com/default/generate-s3-presigned-url", "headers": { "x-api-key": "<private>", "Content-Type": "application/json" }, "body": { "fileName": "<dynamic>", "contentType": "<dynamic>", "folder": "uploads" } } ``` Scroll down to 'Use as' and select **Action** (not Data — this call is triggered by a user action, not a data binding). Click **Initialize call**: in the test data fields, enter `fileName = test.jpg` and `contentType = image/jpeg`. Click Run. If the Lambda is working, Bubble will detect three response fields: `putUrl` (text), `getUrl` (text), and `key` (text). Click Save.
1{2 "method": "POST",3 "url": "https://your-api-gateway-id.execute-api.us-east-1.amazonaws.com/default/generate-s3-presigned-url",4 "headers": {5 "x-api-key": "<private>",6 "Content-Type": "application/json"7 },8 "body": {9 "fileName": "<dynamic>",10 "contentType": "<dynamic>",11 "folder": "uploads"12 }13}Pro tip: If Initialize call returns an error, check Bubble's Logs tab → API Connector logs for the raw response. A 403 from API Gateway usually means the x-api-key header is wrong or missing. A 500 from Lambda means the function threw an error — check Lambda's CloudWatch logs in the AWS Console.
Expected result: The 'Get Presigned URL' API Connector call is initialized and Bubble has detected the three return fields (putUrl, getUrl, key) with correct text data types.
Build the Bubble Backend Workflow for presigned URL generation
In Bubble's editor, go to the Backend Workflows section (in the left panel under Workflows). Click 'Add a new Backend Workflow'. Name it `Generate S3 Presigned URL`. Add two parameters that this workflow will accept: - `file_name` (text) - `content_type` (text) In the workflow body, add one action: Plugins → API Connector → Get Presigned URL. Map the parameters: - `fileName` → Parameter file_name - `contentType` → Parameter content_type After the API Connector action, add 'Return data from a backend workflow'. Configure two return fields: - `put_url` = result of Step 1 (Get Presigned URL) → putUrl - `s3_key` = result of Step 1 → key Save the Backend Workflow. Now build the client-side trigger. On the page with your file upload button, add a workflow triggered by the button click: 1. Action: Backend Workflows → Schedule API Workflow → select `Generate S3 Presigned URL`, pass `file_name = FileUploader A's value's name` and `content_type = FileUploader A's value's file type` 2. After the backend workflow returns, use 'Set App State variable' to store `put_url` and `s3_key` 3. Add a 'Make a HTTP PUT request' action (via Toolbox plugin's 'Run JavaScript') targeting the stored `put_url`, with the file bytes from the FileUploader and the Content-Type header matching the file's type 4. After the PUT succeeds, create a database Thing (e.g. Upload) with field `s3_key` = the stored key, linked to the current user This stores the S3 object key — not a URL — in Bubble's database. To display the file later, call the Backend Workflow again to generate a fresh presigned GET URL on demand.
Pro tip: Store only the S3 object key (e.g. `uploads/uuid/photo.jpg`) in Bubble's database — never store the presigned URL itself. Presigned URLs expire (default 15 minutes for PUT, 24 hours for GET in this setup). Each time a user needs to view a file, generate a fresh presigned GET URL from the stored key.
Expected result: The Backend Workflow 'Generate S3 Presigned URL' appears in the Backend Workflows section, accepts file_name and content_type parameters, calls Lambda, and returns put_url and s3_key. The client-side workflow triggers it on button click.
Test the full upload flow and set up Bubble privacy rules
Deploy your Bubble app (or preview it in test mode — Bubble preview runs on HTTPS, which satisfies CORS). Open the page with your file uploader. Select a test image file (JPEG, under 5 MB for the first test). Click the upload button. Watch the Logs tab in Bubble → Workflow logs to see the Backend Workflow execute. You should see the 'Get Presigned URL' API Connector call succeed with a 200 response. Then watch your browser's DevTools Network tab — you should see a PUT request going directly to `s3.amazonaws.com` with a long signed URL and a 200 OK response. In the AWS Console, open your S3 bucket → Objects tab — the uploaded file should appear under the `uploads/` prefix. Finally, set up Bubble privacy rules for your Upload data type. Go to Data tab → Data types → Upload → Privacy. Add a rule: When = Current User is the file's creator → Allow find in searches: Yes, Allow reading all fields: Yes. Without this rule, anyone who can query your Bubble database can potentially read all upload records — even if the S3 files themselves are private. For any Bubble data type that stores references to sensitive user-generated files, privacy rules are the last line of defense inside Bubble's own database layer. They work alongside S3 bucket policies and presigned URL expiry to create a defense-in-depth approach.
Pro tip: If the PUT to S3 returns a CORS error in DevTools despite your CORS configuration, double-check that the `AllowedOrigins` in the S3 bucket CORS rule exactly matches your Bubble app's full origin (including https:// and the correct subdomain). A trailing slash or mismatched subdomain will cause the browser to reject the CORS preflight response.
Expected result: File uploaded successfully to your S3 bucket. S3 key stored in Bubble's database linked to the current user. Bubble privacy rules protect the Upload records from unauthorized access. The full integration is working end-to-end.
Common use cases
User-uploaded media library
Let users upload profile photos, portfolio images, or documents from your Bubble app and store them in your own S3 bucket, giving you full control over storage costs, retention policies, folder structure, and CDN delivery via CloudFront.
Build a Bubble workflow where a user clicks 'Upload Photo', the app calls a Backend Workflow to get a presigned URL, then PUTs the file directly to S3 and saves the S3 object key to the user's profile record in Bubble's database.
Copy this prompt to try it in Bubble
Private document sharing with expiring links
Store confidential documents (contracts, invoices, medical records) in a private S3 bucket and generate short-lived presigned GET URLs (15–60 minutes) in a Bubble Backend Workflow when an authorized user requests access, so the document URLs cannot be shared or bookmarked permanently.
When an authenticated Bubble user clicks 'Download Contract', trigger a Backend Workflow that calls Lambda to generate a 30-minute presigned GET URL for the document key, then display it as a link in a popup.
Copy this prompt to try it in Bubble
Multi-tenant SaaS file storage
Organize uploads by customer account using S3 key prefixes (e.g. `tenants/{accountId}/uploads/{filename}`) so each tenant's files are logically separated, and use IAM bucket policies to enforce access boundaries at the AWS level independent of Bubble's database queries.
When a user in Bubble uploads a file, pass their account ID to the Backend Workflow so the Lambda generates a presigned URL with key prefix `tenants/{accountId}/{uuid}/{filename}`, then store that full key in the Uploads data type linked to the account.
Copy this prompt to try it in Bubble
Troubleshooting
Browser shows a CORS error on the S3 PUT request even though the presigned URL looks correct
Cause: The S3 bucket CORS configuration either hasn't been saved, doesn't include your Bubble app's exact origin, or uses HTTP when Bubble serves on HTTPS.
Solution: Open your S3 bucket → Permissions → CORS configuration. Verify the AllowedOrigins array contains your full Bubble domain with https:// prefix (e.g. 'https://yourapp.bubbleapps.io'). Wait 30 seconds after saving — changes propagate quickly but not instantly. Also confirm the AllowedMethods array includes 'PUT'.
S3 returns 'SignatureDoesNotMatch' on the PUT request
Cause: The Content-Type header sent in the browser PUT request does not exactly match the ContentType value that was signed into the presigned URL when it was generated.
Solution: Ensure your client-side PUT request sends exactly the same Content-Type that was passed to the Lambda function. In Bubble, use the File Uploader element's 'file type' dynamic value to capture the MIME type, pass it to the Backend Workflow, and include it as a header in the PUT action. Even a difference between 'image/jpeg' and 'image/jpg' will cause this error.
The Backend Workflow runs but the API Connector call fails with a 403 error
Cause: The x-api-key header for API Gateway is missing, incorrect, or not marked as Private in the Bubble API Connector configuration.
Solution: In the Bubble API Connector, open the 'AWS Lambda S3' group and verify the x-api-key shared header value matches your API Gateway API key exactly. Make sure the Private checkbox is checked. In Bubble's Logs tab, click the failed workflow run to see the raw response body — API Gateway returns a specific error message ('Forbidden', 'Missing Authentication Token') that identifies the auth issue.
Initialize call in the API Connector returns an error: 'There was an issue setting up your call'
Cause: The Lambda function returned a non-200 status code during the initialization test, usually because test parameters were missing or the Lambda hasn't been deployed yet.
Solution: Enter real test values in the Initialize call fields: fileName = 'test.jpg', contentType = 'image/jpeg'. Make sure your Lambda is deployed (not just saved in the editor). Test the Lambda directly from the AWS Console's Test tab first to confirm it works before testing from Bubble.
Upload button does nothing and no Backend Workflow appears in the Logs tab
Cause: Bubble Backend Workflows are not available on the free plan. If the app is on a free plan, scheduling Backend Workflows silently fails.
Solution: Go to your Bubble app's Settings → Plan and confirm you are on a paid plan (Starter or above). Backend Workflows (server-side API calls) require a paid plan. On the free plan, you can only make client-side API Connector calls, which is not safe for secret key operations like S3 presigned URL generation.
Best practices
- Store only the S3 object key in Bubble's database — never the presigned URL itself. Presigned URLs expire; keys do not. Generate fresh GET URLs on demand from stored keys.
- Scope IAM users to the minimum required permissions: s3:PutObject and s3:GetObject on the specific bucket ARN only. Never grant s3:DeleteObject or s3:ListBucket unless your app explicitly requires those operations.
- Use randomized prefixes in your S3 key structure (e.g. `uploads/{uuid}/{filename}`) to avoid S3 key naming collisions and to benefit from S3's automatic partition distribution for high-throughput workloads.
- Configure S3 bucket CORS with your exact production domain, not a wildcard (*). Using AllowedOrigins: ['*'] during development is acceptable for testing, but must be replaced with your specific Bubble domain before going live.
- Enable Bubble privacy rules on every data type that stores references to S3 uploads. S3 bucket permissions protect the files at the storage layer; Bubble privacy rules protect the metadata (keys, filenames, user associations) at the database layer.
- Keep presigned PUT URL expiry short (900 seconds / 15 minutes is a good default). Long-lived presigned URLs increase the window of opportunity if a URL is intercepted. Presigned GET URLs for display can be longer (1–24 hours) since they are read-only.
- Monitor S3 upload costs in the AWS Cost Explorer. Set a billing alert for unexpected spikes — a sudden surge in PUT requests may indicate your presigned URL generation endpoint is being abused. Rate-limit your Lambda via API Gateway usage plans.
- For apps with significant upload volume, use S3 lifecycle rules to automatically transition old files to cheaper storage classes (S3 Standard-IA, Glacier) after a set number of days, reducing long-term storage costs without any Bubble-side changes.
Alternatives
Firebase Storage (via the Zeroqode Firebase plugin) handles file uploads directly from Bubble without a custom Lambda function, using Firebase's client SDK with Firebase Security Rules instead of SigV4 signing. It's simpler to set up and cheaper for low-volume apps, but gives you less control over CDN delivery and storage cost structure compared to S3.
Backblaze B2 is S3-compatible storage at a significantly lower price point (free egress to Cloudflare CDN, ~$0.006/GB storage). If cost is the primary concern and you don't need the full AWS ecosystem, B2 uses the same presigned URL pattern as S3 and can be swapped in with minimal code changes to the Lambda function.
Dropbox offers a simple REST API with OAuth token auth (no SigV4 complexity) and has a native API Connector integration in Bubble. It's much easier to connect but is designed for user-owned file storage rather than app-controlled object storage. Use Dropbox when your users need to access and manage files from their own Dropbox accounts.
Frequently asked questions
Can I use Bubble's built-in file uploader to upload to my own S3 bucket?
No. Bubble's built-in File Uploader element stores files on Bubble's own managed S3 bucket under Bubble's AWS account — you have no control over that bucket. To upload to your own S3 bucket, you must bypass the native uploader and implement the presigned URL workflow described in this guide. The two systems are completely separate.
Do I need a Lambda function, or can I generate presigned URLs another way?
You need some server-side code execution to generate SigV4-signed URLs because the signing algorithm requires your IAM secret. Lambda is the most natural choice since it's already in AWS, but any serverless function will work: Cloudflare Workers (using the aws4fetch library), Supabase Edge Functions, or Vercel Edge Functions. The key constraint is that the IAM credentials must never reach the browser.
What happens if my presigned URL expires before the user uploads the file?
The S3 PUT request returns a 403 Access Denied error. Presigned PUT URLs expire at the time you specify (900 seconds / 15 minutes in this guide). If your users routinely take longer to select and upload files, increase the PUT URL expiry in the Lambda code (up to 7 days for IAM user credentials, or 1 hour for role-based credentials). You can also add a client-side timer that re-fetches a fresh presigned URL if it's been more than 10 minutes since the last one was generated.
Is this integration available on Bubble's free plan?
No. The presigned URL generation step requires a Bubble Backend Workflow, which is only available on paid plans (Starter and above). On the free plan, all workflows run client-side, and putting IAM credentials in client-side workflows would expose them in the browser. There is no safe way to implement custom S3 uploads on Bubble's free plan.
How do I display uploaded files from S3 in my Bubble app?
Store the S3 object key in Bubble's database when the upload completes. When you need to display a file, trigger a Backend Workflow that calls Lambda to generate a fresh presigned GET URL for that key (with a 1–24 hour expiry), then bind the returned URL to an image or link element in Bubble. Never store presigned GET URLs in the database — they expire and become broken links.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation