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
- A GraphQL server (Apollo Server 4, GraphQL Yoga, Mercurius — any works) with resolvers you can add.
- Object storage with presigned PUT or POST support, and
@aws-sdk/s3-request-presigneror your cloud’s equivalent. - CORS on the bucket allowing
PUTfrom your web origin — see configuring CORS for GCS and Azure Blob uploads or the S3 equivalent. - An
uploadstable to track pending uploads by ID.
Three ways to get a file into a GraphQL mutation
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. ContentLengthin the signed command. The presigned PUT then only accepts a body of exactly the declared size. A client that lies aboutsizein 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.
completeUploadchecks storage. The client saying “done” proves nothing.HeadObjectconfirms the object exists with the declared size before anAssetis 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
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
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.