Skip to main content

Command Palette

Search for a command to run...

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

Using Pre-Signed URLs for Scalable, Secure File Handling in GraphQL APIs

Updated
7 min read
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.

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

Source code

⬆️ 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

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

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

type UploadUrlResponse {
  url: AWSURL!
  fields: AWSJSON!
}
💡
You might wonder why we use a Mutation to generate a pre-signed URL instead of a Query. That’s because creating a pre-signed URL is an action with side effects: i.e. it generates temporary credentials that can be used to perform actions in the system.

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.

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,
    },
  };
};
💡
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 s3:PutObject action on the destination bucket.

The response looks like this

{
  "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.

The fields property is stringified in the AppSync response. For readability, I have parsed and truncated it in the above code snippet.

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

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.

⬇️ 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.

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.

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

{
  "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.

Thanks for reading, and happy building! 🚀