# Secure Image Uploads from Expo to Amazon S3 Using Presigned URLs

Uploading images directly from an Expo app to Amazon S3 can make your uploads faster and reduce work on your backend server.

However, you should **never put AWS access keys inside your Expo app**. Anyone could extract those keys from the application and access your S3 bucket.

The safer solution is to use an **S3 presigned URL**.

In this guide, we will build the following flow:

```text
Expo app
   ↓
Request an upload URL from the backend
   ↓
Backend creates a temporary S3 presigned URL
   ↓
Expo uploads the image directly to S3
   ↓
Backend stores the S3 object key
```

Amazon S3 presigned URLs allow an application to upload a specific object without receiving AWS credentials. The URL only works for the action and time period selected by the backend.

* * *

## What Is a Presigned URL?

A presigned URL is a temporary URL created by your backend.

For example:

```text
https://my-bucket.s3.amazonaws.com/uploads/image.jpg?X-Amz-Signature=...
```

Your Expo app can send a `PUT` request to this URL and upload a file directly to S3.

The Expo app never receives:

```env
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
```

These credentials remain only on your backend server.

A presigned URL should also be treated as a temporary password. Anyone who gets the URL may be able to use it until it expires.

* * *

## What We Will Build

We need two parts:

1.  A Node.js backend that generates the presigned URL.
    
2.  An Expo app that selects and uploads an image.
    

The backend will return:

```json
{
  "uploadUrl": "https://s3-presigned-url...",
  "key": "uploads/user-id/random-image-id.jpg"
}
```

The Expo app will upload the image using `uploadUrl`.

The `key` is the permanent location of the file inside S3. You should store this key in your database instead of storing the temporary presigned URL.

* * *

# Part 1: Configure the Backend

This example uses Node.js, Express and AWS SDK v3.

Install the required packages:

```bash
bun add express @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
```

You can also use npm:

```bash
npm install express @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
```

Create your environment variables:

```env
AWS_S3_BUCKET=my-app-bucket
AWS_S3_REGION=ap-south-1

AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
```

Do not add these variables to the Expo application.

They must exist only on your backend server.

* * *

## Create the S3 Client

Create a file named `s3.ts`:

```ts
import { S3Client } from "@aws-sdk/client-s3";

export const s3Client = new S3Client({
  region: process.env.AWS_S3_REGION,
});
```

The AWS SDK will read the credentials from the backend environment.

* * *

## Create the Presigned URL Endpoint

Create an endpoint that receives the image type and returns an upload URL.

```ts
import express from "express";
import { randomUUID } from "node:crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

import { s3Client } from "./s3";

const app = express();

app.use(express.json());

const allowedImageTypes: Record<string, string> = {
  "image/jpeg": "jpg",
  "image/png": "png",
  "image/webp": "webp",
};

app.post("/api/uploads/presign", async (req, res) => {
  try {
    const { contentType, fileSize } = req.body;

    if (!contentType || !allowedImageTypes[contentType]) {
      return res.status(400).json({
        message: "Only JPG, PNG and WebP images are allowed.",
      });
    }

    const maximumFileSize = 10 * 1024 * 1024;

    if (
      typeof fileSize !== "number" ||
      fileSize <= 0 ||
      fileSize > maximumFileSize
    ) {
      return res.status(400).json({
        message: "The image must be smaller than 10 MB.",
      });
    }

    // Replace this with the ID of the logged-in user.
    const userId = "example-user-id";

    const extension = allowedImageTypes[contentType];

    const key = `uploads/${userId}/${randomUUID()}.${extension}`;

    const command = new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET,
      Key: key,
      ContentType: contentType,
    });

    const uploadUrl = await getSignedUrl(s3Client, command, {
      expiresIn: 300,
    });

    return res.json({
      uploadUrl,
      key,
    });
  } catch (error) {
    console.error("Could not create presigned URL:", error);

    return res.status(500).json({
      message: "Could not prepare the image upload.",
    });
  }
});

app.listen(3000, () => {
  console.log("Server running on port 3000");
});
```

Here, the presigned URL expires after:

```ts
expiresIn: 300
```

That means it remains valid for five minutes.

AWS allows presigned URLs to have longer expiration periods, but a small expiration time is safer for normal image uploads. A URL can also stop working earlier when the credentials used to create it expire.

* * *

## Why Generate the File Name on the Backend?

Do not allow the Expo app to provide the complete S3 key.

A user could try sending something unsafe, such as:

```text
admin/profile.jpg
```

Instead, the backend creates the key:

```ts
const key = `uploads/${userId}/${randomUUID()}.${extension}`;
```

This provides:

*   A separate folder for every user.
    
*   A unique file name.
    
*   Protection against accidental overwriting.
    
*   Better control over where files are uploaded.
    

Amazon S3 replaces an existing object when another file is uploaded using the same key. Using a unique UUID prevents this problem.

* * *

# Part 2: Upload the Image from Expo

Install Expo Image Picker and Expo File System:

```bash
npx expo install expo-image-picker expo-file-system
```

Expo Image Picker provides the native interface for choosing images from the phone.

* * *

## Complete Expo Upload Example

Create a component such as `ImageUploader.tsx`:

```tsx
import { useState } from "react";
import {
  ActivityIndicator,
  Alert,
  Button,
  Image,
  StyleSheet,
  View,
} from "react-native";

import * as ImagePicker from "expo-image-picker";
import { File } from "expo-file-system";
import { fetch as expoFetch } from "expo/fetch";

const API_URL = "https://your-backend.example.com";

type PresignResponse = {
  uploadUrl: string;
  key: string;
};

export default function ImageUploader() {
  const [imageUri, setImageUri] = useState<string | null>(null);
  const [uploading, setUploading] = useState(false);

  const selectAndUploadImage = async () => {
    try {
      setUploading(true);

      const pickerResult =
        await ImagePicker.launchImageLibraryAsync({
          mediaTypes: ["images"],
          allowsEditing: true,
          quality: 0.8,
        });

      if (pickerResult.canceled) {
        return;
      }

      const asset = pickerResult.assets[0];

      const contentType = asset.mimeType ?? "image/jpeg";
      const fileSize = asset.fileSize ?? 0;

      const presignResponse = await expoFetch(
        `${API_URL}/api/uploads/presign`,
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",

            // Add your application's authentication token here.
            // Authorization: `Bearer ${accessToken}`,
          },
          body: JSON.stringify({
            contentType,
            fileSize,
          }),
        }
      );

      if (!presignResponse.ok) {
        const errorData = await presignResponse.json();

        throw new Error(
          errorData.message ?? "Could not prepare the upload."
        );
      }

      const { uploadUrl, key } =
        (await presignResponse.json()) as PresignResponse;

      const file = new File(asset.uri);

      const s3Response = await expoFetch(uploadUrl, {
        method: "PUT",
        headers: {
          "Content-Type": contentType,
        },
        body: file,
      });

      if (!s3Response.ok) {
        throw new Error(
          `S3 upload failed with status ${s3Response.status}.`
        );
      }

      setImageUri(asset.uri);

      console.log("Uploaded S3 key:", key);

      Alert.alert("Success", "Your image was uploaded.");
    } catch (error) {
      const message =
        error instanceof Error
          ? error.message
          : "Something went wrong.";

      Alert.alert("Upload failed", message);
    } finally {
      setUploading(false);
    }
  };

  return (
    <View style={styles.container}>
      {imageUri && (
        <Image
          source={{ uri: imageUri }}
          style={styles.image}
        />
      )}

      {uploading ? (
        <ActivityIndicator size="large" />
      ) : (
        <Button
          title="Select and upload image"
          onPress={selectAndUploadImage}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    gap: 24,
    alignItems: "center",
    justifyContent: "center",
    padding: 24,
  },
  image: {
    width: 220,
    height: 220,
    borderRadius: 16,
  },
});
```

Expo’s current File System API lets us create a `File` from a local URI and send that file directly as the body of an `expo/fetch` request. This avoids converting the complete image to Base64.

* * *

## The Important Upload Code

The most important section is:

```ts
const file = new File(asset.uri);

const s3Response = await expoFetch(uploadUrl, {
  method: "PUT",
  headers: {
    "Content-Type": contentType,
  },
  body: file,
});
```

The file must be sent directly as the request body.

Do not wrap it inside JSON:

```ts
// Wrong
body: JSON.stringify({
  file,
});
```

Do not use `FormData` for this normal presigned `PUT` request:

```ts
// Not needed for a presigned PUT URL
const formData = new FormData();
```

The S3 URL was created for a `PUT` request containing the raw file.

* * *

# Part 3: Save the Uploaded Image

After the S3 upload succeeds, the Expo app has the object key:

```text
uploads/example-user-id/550e8400-e29b-41d4-a716-446655440000.jpg
```

Send this key to your backend:

```ts
await expoFetch(`${API_URL}/api/profile/avatar`, {
  method: "PATCH",
  headers: {
    "Content-Type": "application/json",
    // Authorization: `Bearer ${accessToken}`,
  },
  body: JSON.stringify({
    avatarKey: key,
  }),
});
```

Your database could store:

```text
avatar_object_key:
uploads/example-user-id/550e8400-e29b-41d4-a716-446655440000.jpg
```

Do not store the presigned upload URL because it will expire.

For a private bucket, your backend can later create a temporary download URL. You can also place CloudFront in front of S3 when you need controlled image delivery.

* * *

# Content-Type Must Match

The backend creates the presigned URL using:

```ts
ContentType: contentType
```

Therefore, the Expo upload must send the same value:

```ts
headers: {
  "Content-Type": contentType,
}
```

For example, when the backend signs the request using:

```text
image/jpeg
```

but Expo sends:

```text
image/png
```

S3 may return:

```text
SignatureDoesNotMatch
```

AWS recommends checking the content type, bucket region, expiration time and the unmodified URL when troubleshooting signature errors.

* * *

# S3 CORS Configuration for Expo Web

CORS normally matters when the upload runs inside a web browser.

Open:

```text
AWS Console
→ S3
→ Your bucket
→ Permissions
→ Cross-origin resource sharing
```

Add a configuration like this:

```json
[
  {
    "AllowedHeaders": [
      "Content-Type"
    ],
    "AllowedMethods": [
      "PUT"
    ],
    "AllowedOrigins": [
      "https://your-app-domain.com"
    ],
    "ExposeHeaders": [
      "ETag"
    ],
    "MaxAgeSeconds": 3600
  }
]
```

During local development, you may also add your local web address:

```json
"AllowedOrigins": [
  "http://localhost:8081",
  "https://your-app-domain.com"
]
```

S3 CORS rules define which browser origins, HTTP methods and headers may access the bucket. The origin must match `AllowedOrigins`, and `PUT` must exist inside `AllowedMethods`.

Avoid using this in production:

```json
"AllowedOrigins": ["*"]
```

Allow only your real application domains.

* * *

# Common Errors

## 1\. SignatureDoesNotMatch

Check that:

*   The `Content-Type` is exactly the same.
    
*   The URL has not expired.
    
*   The URL was not changed.
    
*   Your backend uses the correct S3 region.
    
*   You are sending a `PUT` request.
    

* * *

## 2\. AccessDenied

Your backend AWS user or role may not have permission to upload the object.

It needs permission similar to:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::my-app-bucket/uploads/*"
    }
  ]
}
```

Replace:

```text
my-app-bucket
```

with your real bucket name.

Do not give full S3 administrator access when the backend only needs to upload files.

* * *

## 3\. Upload Works on Android but Fails on Expo Web

This normally means your S3 CORS configuration is missing or incorrect.

Make sure the web app’s exact domain exists in:

```json
"AllowedOrigins"
```

Also make sure `PUT` is allowed.

* * *

## 4\. Uploaded Image Cannot Be Opened

The upload may be successful while the S3 object remains private.

A private bucket will not provide public access just because the file was uploaded successfully.

Use one of these options:

*   Generate a presigned `GET` URL.
    
*   Deliver images through CloudFront.
    
*   Let your backend stream the file.
    
*   Make only the required image path public.
    

Keeping the bucket private is normally the safer default.

* * *

# Security Checklist

Before using this in production:

*   Keep AWS credentials only on the backend.
    
*   Authenticate the presigned URL endpoint.
    
*   Generate object keys on the backend.
    
*   Use short expiration times.
    
*   Allow only required image types.
    
*   Add upload limits.
    
*   Keep the S3 bucket private.
    
*   Give the AWS user or role only `s3:PutObject` permission.
    
*   Store the S3 object key, not the presigned URL.
    
*   Do not log the complete presigned URL.
    
*   Verify or process uploaded files on the backend when security is important.
    

Remember that the file size and content type sent by the Expo app can be changed by a malicious user. Client-side validation improves the user experience, but sensitive applications should inspect the uploaded object before accepting it permanently.

* * *

# Final Flow

The complete upload flow is:

```text
1. User selects an image in the Expo app.

2. Expo sends the image type and size to the backend.

3. The backend authenticates the user.

4. The backend generates a unique S3 object key.

5. The backend creates a short-lived presigned PUT URL.

6. Expo uploads the raw file directly to S3.

7. Expo sends the returned S3 key to the backend.

8. The backend stores the key in the database.
```

This approach is secure, simple and efficient because the actual image does not need to pass through your backend server.
