# How to download and upload files to Amazon S3 with AWS AppSync

One of the questions I’m most often asked by people new to **AWS AppSync** is: **“How do I let my users upload or download files?”**

The short answer is: *you can’t* — at least not directly.

While GraphQL is built on top of HTTP, it is **not designed for transferring large binary payloads**. GraphQL excels as a **control plane**, not as a data transport layer.

Fortunately, when working with AWS AppSync and the AWS ecosystem, file storage almost always means **Amazon S3**. And that opens the door to a much cleaner and more scalable approach: [**S3 pre-signed URLs**](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html).

In this article, I’ll walk through how to use **AWS AppSync as the orchestrator** for secure file uploads and downloads, while letting Amazon S3 handle the heavy lifting.

## 🏗️ High-Level Architecture

A **pre-signed URL** is a secure, time-limited URL that grants permission to upload or download an object from an Amazon S3 bucket. Instead of exposing AWS credentials or making your bucket public, the URL embeds a temporary signature that authorizes a specific operation (GET or PUT) on a specific object.

Once generated, the URL can be used **directly by the client** to interact with S3 in a secure and efficient way — without routing file data through your API.

Under the hood, a pre-signed URL is created using the **AWS credentials of the entity that generates it**. In the context of AWS AppSync, this is typically a Lambda function configured as an AppSync data source and resolver.

Those credentials define:

* Which bucket and object key can be accessed
    
* Which operation is allowed (upload or download)
    
* What type(s) of files can be uploaded (image, pdf, etc.)
    
* The maximum file size permitted
    
* How long the URL remains valid
    
* Metadata on the object (For example, the user id of the file uploader)
    

The overall flow looks like this:

1. The client requests an upload or download URL via AppSync
    
2. AppSync validates the request (authentication, authorization, file constraints, etc.)
    
3. AppSync generates a pre-signed URL using its execution role
    
4. The client uploads or downloads the file **directly from S3**
    

![](https://img.plantuml.biz/plantuml/png/hP6nRa8n34NtV8N5_gQteQe2KQbB9n0VO193ewP9S6nLzEkR007UmjGbYkwvyVLL5aMGHR-3CMWbCQo2foY01UpvPdBbtlgCLLtcl3b5soXcFq6ppGWxjLyaiuRBQCo1asRG718wSva6msjxEOSr7PMAN2ae1rFr6rwgV5QxjoW461rW5HBxNm8jn1FlEqNYvijCG_67IAwFtQu_Ul3BCFHaKOwFmVVLoZY7xGNiVI13qVKQBlt4PqKw__Sgjy5Foap27pojprjdzqQBUhOl_mC0 align="center")

> [Source code](https://editor.plantuml.com/uml/hP6nRa8n34NtV8N5_gQteQe2KQbB9n0VO193ewP9S6nLzEkR007UmjGbYkwvyVLL5aMGHR-3CMWbCQo2foY01UpvPdBbtlgCLLtcl3b5soXcFq6ppGWxjLyaiuRBQCo1asRG718wSva6msjxEOSr7PMAN2ae1rFr6rwgV5QxjoW461rW5HBxNm8jn1FlEqNYvijCG_67IAwFtQu_Ul3BCFHaKOwFmVVLoZY7xGNiVI13qVKQBlt4PqKw__Sgjy5Foap27pojprjdzqQBUhOl_mC0)

## ⬆️ Generate a pre-signed upload URL

The core idea is simple: the client first asks AppSync for permission to upload a file, and AppSync responds with everything the client needs to upload **directly to S3**.

First, we need a mutation that lets the client **declare its intent** to upload a file.

Typically, the mutation needs:

* The file name
    
* The file type (mime type)
    

and it returns:

* the pre-signed upload URL
    
* the fields that the client must include in the upload request
    

Those fields usually include things like:

* content type constraints
    
* metadata
    
* the upload policy
    
* the AWS SigV4 credentials
    

```graphql
type Mutation {
  generateUploadUrl(input: GenerateUploadUrlInput!): UploadUrlResponse!
}

input GenerateUploadUrlInput {
  fileName: String!
  contentType: String!
}

type UploadUrlResponse {
  url: AWSURL!
  fields: AWSJSON!
}
```

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">You might wonder why we use a <strong>Mutation</strong> to generate a pre-signed URL instead of a Query. That’s because creating a pre-signed URL is an <strong>action with side effects</strong>: i.e. it generates temporary credentials that can be used to perform actions in the system.</div>
</div>

We also need an AWS Lambda resolver. First, it validates that the user is trying to upload a file type that is accepted. Then, it adds a few additional constraints that the client must respect in order to upload the file:

* Min/max file size: e.g. 5 MB
    
* Validates the file type to prevent the user to upload unwanted files and enforces it into the policy
    
* Makes the acl `private` for the uploaded object
    
* Attaches metadata that tracks the ownership of the file on S3
    

This is also where you would do any sort of validation like:

* Does the user have the right role/permission to upload a file?
    
* Do they have access to uploads for this resource (project/group/etc.)?
    
* etc.
    

```typescript
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
import { S3Client } from '@aws-sdk/client-s3';
import { AppSyncResolverEvent } from 'aws-lambda';
import { get } from 'env-var';

type GenerateUploadUrlInput = {
  input: {
    fileName: string;
    contentType: string;
  };
};

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

const BUCKET_NAME = get('BUCKET_NAME').required().asString();

const ALLOWED_CONTENT_TYPES = ['image/jpeg', 'image/png', 'application/pdf'];

export const handler = async (
  event: AppSyncResolverEvent<GenerateUploadUrlInput>,
) => {
  const { fileName, contentType } = event.arguments.input;

  // Validate allowed content types
  if (!ALLOWED_CONTENT_TYPES.includes(contentType)) {
    return {
      errorMessage: 'Invalid content type',
      errorType: 'InvalidContentType',
    };
  }

  // TODO: Ensure the user is allowed to upload a file

  const userId = 'user123';

  const presignedPost = await createPresignedPost(s3Client, {
    Bucket: BUCKET_NAME,
    Key: `uploads/${userId}/${fileName}`,
    Conditions: [
      // Limit file size to 5 MB
      ['content-length-range', 0, 5 * 1024 * 1024],
      // Ensure the uploaded file type matches the onerequested by the user
      ['eq', '$Content-Type', contentType],
    ],
    Fields: {
      // Make the file private
      acl: 'private',
      // Add custom metadata
      [`x-amz-meta-user-id`]: userId,
    },

    // Set expiration time to 5 minutes
    Expires: 60 * 5,
  });

  return {
    data: {
      url: presignedPost.url,
      fields: presignedPost.fields,
    },
  };
};
```

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Remember. The pre-signed url uses the credentials of the signer (In this case, the Lambda function). This means that your Lambda function must have the necessary IAM policy for the <code>s3:PutObject</code> action on the destination bucket.</div>
</div>

The response looks like this

```json
{
  "data": {
    "generateUploadUrl": {
      "fields": {
        "acl": "private",
        "x-amz-meta-user-id": "user123",
        "bucket": "appsyncs3presignedurlstac-attachmentsbucket8e14a36-hckzhkpulj39",
        "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
        "X-Amz-Credential": "ASIAWMFUPLSIV2CJPSQ6/20251221/us-east-1/s3/aws4_request",
        "X-Amz-Date": "20251221T165443Z",
        "X-Amz-Security-Token": "IQoJb3JpZ2luX2VjEBk...",
        "key": "uploads/user123/picture.jpg",
        "Policy": "eyJleHBpcmF0aW....",
        "X-Amz-Signature": "0ba25cc..."
      },
      "url": "https://appsyncs3presignedurlstac-attachmentsbucket8e14a36-hckzhkpulj39.s3.us-east-1.amazonaws.com/"
    }
  }
}
```

The `url` is your S3 bucket url (The one the client can send the POST request to).

The `fields` must be passed in the form-data upload request performed by the client.

<div data-node-type="callout">
<div data-node-type="callout-emoji">ℹ</div>
<div data-node-type="callout-text">The <code>fields</code> property is stringified in the AppSync response. For readability, I have parsed and truncated it in the above code snippet.</div>
</div>

Use the returned info to upload your file from the client:

```typescript
async function uploadFile({
  file,
  url,
  fields,
}: {
  file: File;
  url: string;
  fields: Record<string, string>;
}) {
  console.log("Uploading file...");

  const request = new XMLHttpRequest();

  const formData = new FormData();

  // Add all pre-signed fields
  Object.entries(fields).forEach(([key, value]) => {
    formData.append(key, value);
  });
  // Add the content-type
  formData.append("Content-type", file.type);
  // add the file
  formData.append("file", file);

  request.open("POST", url, true);
  request.send(formData);
}
```

Once the client uploads the file, it will be visible on the S3 bucket at the indicated location. The metadata will also be attached to the object.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766339944861/c998263e-7012-4cc6-9a9f-8357336d94c9.png align="center")

## ⬇️ Generate a pre-signed download url

Now that they can upload files, your users might also want to download them. Just like with uploads, you can generate a pre-signed download URL.

To do this, we need a new Mutation that takes the file name (object key) as input and returns the URL to download the file.

```graphql
type Mutation {
  generateDownloadUrl(fileName: String!): DownloadUrlResponse!
}

type DownloadUrlResponse {
  url: AWSURL!
}
```

Here too, a Lambda resolver generates the signed url. Optionally, this is also where you can perform authorization checks.

```typescript
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { AppSyncResolverEvent } from 'aws-lambda';
import { get } from 'env-var';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

type GenerateDownloadUrlInput = {
  fileName: string;
};

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

const BUCKET_NAME = get('BUCKET_NAME').required().asString();

export const handler = async (
  event: AppSyncResolverEvent<GenerateDownloadUrlInput>,
) => {
  const { fileName } = event.arguments;

  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: fileName,
  });

  const url = await getSignedUrl(s3Client, command, {
    expiresIn: 60 * 5,
  });

  return {
    data: {
      url: url,
    },
  };
};
```

Response example

```json
{
  "data": {
    "generateDownloadUrl": {
      "url": "https://appsyncs3presignedurlstac-attachmentsbucket8e14a36-hckzhkpulj39.s3.us-east-1.amazonaws.com/uploads/user123/picture.jpg?X-Amz-Algorithm=..."
    }
  }
}
```

## Conclusion

By using **pre-signed S3 URLs**, you keep file transfers out of your GraphQL API while still maintaining full control over **who can upload or download what, and under which conditions**. AppSync becomes the gatekeeper — validating intent, enforcing security rules, and issuing short-lived permissions — while Amazon S3 handles the heavy lifting with speed and scale.

This approach gives you:

* Secure, direct uploads and downloads
    
* Clear separation between control plane and data plane
    
* Fine-grained control over authorization, file size, MIME types, and metadata
    

You can find the code of this project on [**Github**](https://github.com/bboure/appsync-upload-to-s3)**.**

Thanks for reading, and happy building! 🚀
