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:
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:
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:
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:
A Node.js backend that generates the presigned URL.
An Expo app that selects and uploads an image.
The backend will return:
{
"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:
bun add express @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
You can also use npm:
npm install express @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Create your environment variables:
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:
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.
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:
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:
admin/profile.jpg
Instead, the backend creates the key:
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:
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:
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:
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:
// Wrong
body: JSON.stringify({
file,
});
Do not use FormData for this normal presigned PUT request:
// 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:
uploads/example-user-id/550e8400-e29b-41d4-a716-446655440000.jpg
Send this key to your backend:
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:
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:
ContentType: contentType
Therefore, the Expo upload must send the same value:
headers: {
"Content-Type": contentType,
}
For example, when the backend signs the request using:
image/jpeg
but Expo sends:
image/png
S3 may return:
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:
AWS Console
→ S3
→ Your bucket
→ Permissions
→ Cross-origin resource sharing
Add a configuration like this:
[
{
"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:
"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:
"AllowedOrigins": ["*"]
Allow only your real application domains.
Common Errors
1. SignatureDoesNotMatch
Check that:
The
Content-Typeis exactly the same.The URL has not expired.
The URL was not changed.
Your backend uses the correct S3 region.
You are sending a
PUTrequest.
2. AccessDenied
Your backend AWS user or role may not have permission to upload the object.
It needs permission similar to:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-app-bucket/uploads/*"
}
]
}
Replace:
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:
"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
GETURL.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:PutObjectpermission.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:
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.





