Uploading Files Through GraphQL APIs

Do not put file bytes in GraphQL: add a createUpload mutation that returns a presigned URL and an upload ID, PUT the file straight to storage with that URL, then call a completeUpload(id) mutation that verifies the object and attaches it to your domain entity; use the GraphQL multipart request spec (Upload scalar over multipart/form-data) only for small files on servers that have CSRF protection for it enabled.

GraphQL is built around JSON, and JSON has no binary type. Teams discover this when the first “avatar” field ships as a base64 string in a mutation: requests grow by a third, the GraphQL server buffers the whole thing in memory to parse it, body limits start rejecting phone photos, and the query logger faithfully records megabytes of base64. The fix is to let GraphQL coordinate uploads while something better suited moves the bytes. This page belongs to base64 vs binary encoding in upload fundamentals and browser APIs. The signing step is the one from generating secure presigned URLs with AWS SDK v3.

When to use this approach

  • Your API is GraphQL-first and clients (web, mobile) already speak it for everything else.
  • Files range from photos to videos, so buffering them through the GraphQL server is not an option.
  • You want uploads authorised by the same resolvers and permission rules as the rest of the API.

Prerequisites

  1. A GraphQL server (Apollo Server 4, GraphQL Yoga, Mercurius — any works) with resolvers you can add.
  2. Object storage with presigned PUT or POST support, and @aws-sdk/s3-request-presigner or your cloud’s equivalent.
  3. CORS on the bucket allowing PUT from your web origin — see configuring CORS for GCS and Azure Blob uploads or the S3 equivalent.
  4. An uploads table to track pending uploads by ID.

Three ways to get a file into a GraphQL mutation

Base64 field, multipart spec and presigned flow compared A base64 string field inflates the payload by a third and is buffered by the GraphQL server. The multipart request spec sends binary through the GraphQL server with an Upload scalar, which works but still routes bytes through the API. The presigned flow uses GraphQL only to create and complete the upload while bytes go directly to storage. Who carries the bytes? base64 in a field +33% payload whole body buffered logged by query tools JSON body limits avoid multipart spec binary, Upload scalar streams through API CSRF risk if misconfigured API scales with bytes small files only presigned flow GraphQL: create + complete bytes: browser → storage any size, resumable API load independent of size default choice
GraphQL is good at deciding who may upload what; it is poor at carrying the bytes, so let storage do that.

Implementation

The schema:

type UploadTicket {
  uploadId: ID!
  url: String!
  method: String!
  headers: [HeaderPair!]!
  expiresAt: String!
}

type HeaderPair { name: String!, value: String! }

type Mutation {
  createUpload(filename: String!, contentType: String!, size: Int!): UploadTicket!
  completeUpload(uploadId: ID!): Asset!
  setAvatar(assetId: ID!): User!
}

Server resolvers (Apollo/Yoga-style, TypeScript):

import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GraphQLError } from "graphql";
import { randomUUID } from "node:crypto";

const s3 = new S3Client({});
const BUCKET = process.env.UPLOAD_BUCKET!;
const MAX = 50 * 1024 * 1024;
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "video/mp4"]);

interface Ctx { userId: string | null; db: { insertUpload(u: object): Promise<void>;
  getUpload(id: string): Promise<{ id: string; userId: string; key: string; size: number; contentType: string } | null>;
  createAsset(a: object): Promise<{ id: string }> } }

export const resolvers = {
  Mutation: {
    async createUpload(_: unknown, a: { filename: string; contentType: string; size: number }, ctx: Ctx) {
      if (!ctx.userId) throw new GraphQLError("Not signed in", { extensions: { code: "UNAUTHENTICATED" } });
      if (!ALLOWED.has(a.contentType)) throw new GraphQLError("File type not allowed", { extensions: { code: "BAD_USER_INPUT" } });
      if (a.size <= 0 || a.size > MAX) throw new GraphQLError(`Files must be under ${MAX / 1048576} MB`, { extensions: { code: "BAD_USER_INPUT" } });

      const uploadId = randomUUID();
      const key = `uploads/${ctx.userId}/${uploadId}`;             // never the client's filename
      const url = await getSignedUrl(s3, new PutObjectCommand({
        Bucket: BUCKET, Key: key, ContentType: a.contentType, ContentLength: a.size,
      }), { expiresIn: 900, signableHeaders: new Set(["content-type", "content-length"]) });

      await ctx.db.insertUpload({ id: uploadId, userId: ctx.userId, key, size: a.size,
        contentType: a.contentType, filename: a.filename.slice(0, 200), status: "pending" });

      return {
        uploadId, url, method: "PUT",
        headers: [{ name: "Content-Type", value: a.contentType }],
        expiresAt: new Date(Date.now() + 900_000).toISOString(),
      };
    },

    async completeUpload(_: unknown, a: { uploadId: string }, ctx: Ctx) {
      const up = await ctx.db.getUpload(a.uploadId);
      if (!up || up.userId !== ctx.userId) throw new GraphQLError("Upload not found", { extensions: { code: "NOT_FOUND" } });
      const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: up.key })).catch(() => null);
      if (!head) throw new GraphQLError("File was not uploaded", { extensions: { code: "BAD_USER_INPUT" } });
      if (head.ContentLength !== up.size) throw new GraphQLError("Size mismatch", { extensions: { code: "BAD_USER_INPUT" } });
      return ctx.db.createAsset({ uploadId: up.id, key: up.key, size: up.size, contentType: up.contentType,
        ownerId: ctx.userId, status: "processing" });
    },
  },
};

Client:

async function gql<T>(query: string, variables: object): Promise<T> {
  const res = await fetch("/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();
  if (json.errors?.length) throw new Error(json.errors[0].message);
  return json.data as T;
}

export async function uploadAvatar(file: File): Promise<string> {
  const { createUpload: t } = await gql<{ createUpload: { uploadId: string; url: string; headers: { name: string; value: string }[] } }>(
    `mutation($f:String!,$t:String!,$s:Int!){ createUpload(filename:$f, contentType:$t, size:$s){ uploadId url headers{name value} } }`,
    { f: file.name, t: file.type, s: file.size });

  const put = await fetch(t.url, {
    method: "PUT",
    body: file,
    headers: Object.fromEntries(t.headers.map((h) => [h.name, h.value])),
  });
  if (!put.ok) throw new Error(`storage rejected the upload: HTTP ${put.status}`);

  const { completeUpload } = await gql<{ completeUpload: { id: string } }>(
    `mutation($id:ID!){ completeUpload(uploadId:$id){ id } }`, { id: t.uploadId });
  await gql(`mutation($a:ID!){ setAvatar(assetId:$a){ id } }`, { a: completeUpload.id });
  return completeUpload.id;
}

Line-by-line on the decisions that matter

  • Validation in createUpload, before any bytes move. Type, size and permission are checked by the resolver, with GraphQL’s normal error extensions, so clients handle upload refusals exactly like any other validation error.
  • ContentLength in the signed command. The presigned PUT then only accepts a body of exactly the declared size. A client that lies about size in the mutation cannot upload more.
  • Server-generated keys. The object key uses the user ID and a fresh UUID. The client filename is stored as metadata, never used as a path — no traversal, no collisions, no overwriting another user’s file.
  • completeUpload checks storage. The client saying “done” proves nothing. HeadObject confirms the object exists with the declared size before an Asset is created; deeper checks (type sniffing, scanning) run asynchronously afterwards. The same principle is detailed in confirming uploads before committing database records.
  • Three round trips, one of them large. The two GraphQL calls are tiny; the PUT carries the bytes directly to storage. The API server’s load no longer depends on file size.

The request sequence

Sequence of a presigned upload coordinated by GraphQL The client calls the createUpload mutation and receives a presigned URL. It PUTs the file directly to storage and receives 200. It then calls completeUpload, which checks the object in storage and returns an Asset. GraphQL coordinates, storage carries client GraphQL API object storage createUpload(type, size) { uploadId, url } PUT url — the bytes, nothing else completeUpload(uploadId) HeadObject Asset { id, status }
Only the thick arrow scales with file size, and it bypasses the API entirely.

If you use the multipart request spec

The GraphQL multipart request specification (implemented by graphql-upload and built into some servers) sends an operations JSON part, a map part and file parts in one multipart/form-data request, and exposes files to resolvers as an Upload scalar with a readable stream. It is convenient for small files in simple setups. Two things need care.

First, CSRF. A multipart/form-data POST is a “simple” request that browsers send cross-site without a preflight, cookies included. A GraphQL endpoint that accepts multipart and authenticates by cookie can be driven from any website. Apollo Server 4 blocks such requests unless they carry a non-simple header such as Apollo-Require-Preflight; if you enable uploads, keep that protection on and send the header from your client.

Second, streaming discipline. The resolver receives a stream; it must pipe it to storage (and enforce a size limit while doing so) rather than buffering it with await stream.toArray(). Otherwise the API server’s memory scales with concurrent upload size — exactly the problem the presigned flow avoids. For anything larger than a few megabytes, prefer the presigned flow.

Configuration gotchas

SignatureDoesNotMatch on the PUT. The client sent a Content-Type different from the one signed, or the browser added charset to it. Return the exact headers from createUpload and send only those.

POST body missing, invalid Content-Type, or JSON object has no keys. A client posted multipart to a GraphQL server without multipart support configured. Either enable the upload middleware or switch the client to the presigned flow.

This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Apollo’s CSRF prevention rejected a multipart request without a preflight header. Send Apollo-Require-Preflight: true from your client — do not disable the protection.

Assets created for files that never arrived. completeUpload trusted the client. Always check storage before creating the record, and expire pending uploads with a lifecycle rule and a sweeper.

Payload sizes for one 6 MB photo

Bytes through the GraphQL server for a 6 MB photo A base64 field sends about 8 megabytes through the GraphQL server. The multipart spec sends about 6 megabytes through it. The presigned flow sends under 2 kilobytes through the GraphQL server, with the 6 megabytes going straight to storage. Bytes handled by the GraphQL server base64 field ≈ 8 MB multipart spec ≈ 6 MB presigned flow < 2 KB (two small mutations) API servers sized for JSON stay sized for JSON; storage absorbs the bytes it was built for.
The presigned flow takes the file off the API's critical path entirely.

Verification

# 1. Mutation returns a ticket.
curl -s localhost:4000/graphql -H 'Content-Type: application/json' -H "Cookie: session=$S" \
  -d '{"query":"mutation{createUpload(filename:\"a.jpg\",contentType:\"image/jpeg\",size:48231){uploadId url}}"}'

# 2. PUT to the returned URL with exactly the signed headers.
curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: image/jpeg' --data-binary @a.jpg "$URL"
# 200

# 3. A size mismatch is refused by storage (signed Content-Length).
head -c 1000 a.jpg | curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: image/jpeg' --data-binary @- "$URL"
# 403

Frequently Asked Questions

Is base64 in GraphQL ever acceptable?

For genuinely tiny payloads — a 2 KB icon, a signature under 10 KB — the overhead is negligible and one mutation is simpler. Put a hard size limit on the field so it cannot become the path for real uploads.

How do I report upload progress with this flow?

The byte transfer is a plain HTTP PUT, so any upload progress technique works: XMLHttpRequest for a single request, or chunked/multipart uploads for large files. GraphQL is not involved in that step.

Can subscriptions tell the client when processing finishes?

Yes. After completeUpload, subscribe to an assetUpdated(id) subscription and push the status as processing steps complete — the GraphQL equivalent of the SSE approach in notifying clients when processing finishes.