# AWS S3

- Tool: Bubble
- Difficulty: Advanced
- Time required: 2–3 hours
- Last updated: July 2026

## TL;DR

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.

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

## Before you start

- 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

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

```
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject"],
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    }
  ]
}
```

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

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

```
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedOrigins": [
      "https://yourapp.bubbleapps.io",
      "https://www.yourdomain.com"
    ],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]
```

**Expected result:** S3 CORS configuration saved. The bucket now accepts PUT and GET requests originating from your Bubble app's domain.

### 3. 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.

```
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 })
  };
};
```

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

### 4. 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.

```
{
  "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"
  }
}
```

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

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

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

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

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

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

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

Prompt example:

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

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

Prompt example:

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

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

Prompt example:

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

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

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

---

Source: https://www.rapidevelopers.com/bubble-integrations/aws-s3
© RapidDev — https://www.rapidevelopers.com/bubble-integrations/aws-s3
