Uploading to GCS with Node.js Client Libraries
To upload from a browser straight to Google Cloud Storage, your Node.js backend mints a short-lived V4 signed POST policy (for single-shot uploads) or a resumable session URI (for large media), and the client sends the bytes with no service-account key ever leaving the server. The @google-cloud/storage package does the signing in one method call.
This is the Google half of direct-to-cloud upload patterns, within Backend Validation & Cloud Storage Architecture. The provider-level trade-offs — pricing, chunk semantics, SDK ergonomics — are compared in S3 vs GCS vs Azure Blob for media uploads; everything below assumes you have already chosen GCS.
When to use this approach
- Your stack runs on Google Cloud and you want the upload bytes to bypass your API server entirely, so a 500 MB file never occupies a request handler.
- You want server-enforced constraints — maximum size, key prefix, exact content type — baked into a credential the browser cannot edit.
- You need resumable uploads for large media that survive a dropped connection, tab reload, or a train going into a tunnel.
Reach for a proxy upload instead when you must inspect bytes before they land, or when your compliance posture forbids clients talking to storage at all. GCS has no direct analogue of S3’s presigned PUT-versus-POST split, so if you are porting from AWS, read presigned POST vs presigned PUT for browser uploads first — on GCS the POST policy is the only form that can enforce a size ceiling.
Prerequisites
- Node 20+ and
npm i @google-cloud/storage@7(the V4 POST policy helper landed in 5.7 and the option shape below is stable through 7.x). - A bucket with uniform bucket-level access enabled. Object ACLs are a legacy path and interact badly with signed uploads.
- A service account holding
roles/storage.objectCreatoron the bucket, plusroles/storage.objectViewerif the same identity runs the verification step. - Credentials via
GOOGLE_APPLICATION_CREDENTIALSpointing at a JSON key, or workload identity plusroles/iam.serviceAccountTokenCreatorgranted to the service account on itself — that second binding is what allows keyless signing. - Bucket CORS allowing your frontend origin and exposing the
Rangeheader. The full cross-provider reference is configuring CORS for GCS and Azure Blob uploads.
How the signing actually works
generateSignedPostPolicyV4 does not call Google. It builds a small JSON policy document, base64-encodes it, and signs that string with RSA-SHA256 using the service account’s private key. The output is a URL plus a flat map of form fields, one of which is the base64 policy and another the hex signature. When the browser POSTs the form, GCS decodes the policy, re-derives the signature from the public half of the key pair, and then evaluates every condition in the document against the request it actually received.
That means two things in practice. First, the conditions are enforced by Google, not by your code — an oversized body is rejected at the storage edge, so a malicious client cannot spend your bandwidth. Second, the browser must replay every returned field verbatim and in order, before the file part; the multipart body itself is part of what the policy constrains.
When there is no key file on disk — Cloud Run, GKE Workload Identity, Cloud Functions — the library cannot sign locally. It falls back to the IAM Credentials signBlob API, which needs roles/iam.serviceAccountTokenCreator. Without that binding you get SigningError: Cannot sign data without client_email or a 403 from iamcredentials.googleapis.com, both at issuance time rather than upload time.
Implementation: a V4 signed POST policy
import { Storage } from "@google-cloud/storage";
import { randomUUID } from "node:crypto";
const storage = new Storage();
const BUCKET = process.env.GCS_BUCKET!;
const MAX_BYTES = 50 * 1024 * 1024;
const ALLOWED = new Set(["image/png", "image/jpeg", "video/mp4"]);
export interface UploadTicket {
url: string;
fields: Record<string, string>;
objectName: string;
expiresAt: string;
}
export async function createUploadTicket(
userId: string,
contentType: string,
): Promise<UploadTicket> {
if (!ALLOWED.has(contentType)) {
// Reject here so the browser gets a 415 instead of a confusing 400 from GCS.
throw new Error(`Unsupported content type: ${contentType}`);
}
const objectName = `incoming/${userId}/${randomUUID()}`;
const file = storage.bucket(BUCKET).file(objectName);
const expires = Date.now() + 10 * 60 * 1000;
const [response] = await file.generateSignedPostPolicyV4({
expires,
// Fields are both sent to the browser AND pinned into the policy.
fields: {
"x-goog-meta-user-id": userId,
"Cache-Control": "private, max-age=0",
},
conditions: [
["content-length-range", 1, MAX_BYTES],
["eq", "$Content-Type", contentType],
["eq", "$Cache-Control", "private, max-age=0"],
],
});
return {
url: response.url,
fields: response.fields,
objectName,
expiresAt: new Date(expires).toISOString(),
};
}
Walking the critical parameters
expiresis an absolute epoch-milliseconds timestamp, and V4 caps it at seven days. Ten minutes is the right order of magnitude for an interactive upload: the clock starts at signing, not at first byte, so a user who picks a file and then makes coffee will fail withPolicy expired. Re-issue on demand rather than lengthening the window.content-length-rangetakes a minimum as well as a maximum. Setting the floor to1rather than0blocks the empty-object upload that otherwise creates a zero-byte row your pipeline has to reap. GCS enforces this itself; the same idea for AWS is spelled out in enforcing upload size limits with S3 POST policies.eqon$Content-Typepins the type. Note the dollar-prefixed form: conditions reference form fields, and omitting the$silently creates a condition that can never match.fieldsare copied into the response map and simultaneously constrained. Anything you put here must also appear inconditionsas aneq— otherwise a client can drop the field and still pass signature verification, because unconstrained extras are permitted.x-goog-meta-user-idbecomes custom object metadata, readable later without a database round trip. Pair it with the dimensions you extract post-upload as described in storing image dimensions and duration metadata.
The key field is generated for you from the File handle, so the object name is signed and the client cannot redirect the upload into someone else’s prefix. The browser side is a plain multipart form:
export async function uploadViaPolicy(
ticket: { url: string; fields: Record<string, string>; objectName: string },
file: File,
): Promise<string> {
const form = new FormData();
for (const [key, value] of Object.entries(ticket.fields)) {
form.append(key, value);
}
form.append("file", file); // MUST be appended last.
const res = await fetch(ticket.url, { method: "POST", body: form });
if (res.status !== 204) {
throw new Error(`GCS upload failed: ${res.status} ${await res.text()}`);
}
return ticket.objectName;
}
A successful POST policy upload returns 204 No Content with an empty body — not 200, and not the XML success document S3 sends. Checking res.ok alone will happily swallow a 200 redirect page from a captive portal, so assert the exact status.
Resumable uploads for large media
Above roughly 100 MB, a single POST is a bad bet: one TCP reset and the whole transfer restarts. GCS’s answer is a resumable session — a URI created once, then written to with successive PUT requests carrying Content-Range. The server acknowledges each write with 308 Resume Incomplete and a Range: bytes=0-<lastCommitted> header telling you exactly how much it durably holds. Sessions live for one week; after that the URI 404s and you start over.
Three protocol facts decide whether your client works:
- Every chunk except the final one must be a multiple of 262144 bytes (256 KiB). GCS rejects anything else outright.
- A
PUTwithContent-Range: bytes */<total>and no body is a status query, not a write. That is how you recover an offset after a reload. - The 308 carries no
Locationheader, sofetchwill not follow it as a redirect — you get the response object back and can readRangefrom it, provided the bucket’s CORS config exposes that header.
The backend creates the session and hands back only the URI:
export async function createResumableSession(
userId: string,
contentType: string,
origin: string,
): Promise<{ uri: string; objectName: string }> {
const objectName = `incoming/${userId}/${randomUUID()}`;
const file = storage.bucket(BUCKET).file(objectName);
const [uri] = await file.createResumableUpload({
metadata: { contentType, metadata: { "user-id": userId } },
origin, // must match a CORS origin exactly
preconditionOpts: { ifGenerationMatch: 0 }, // fail if the object already exists
});
return { uri, objectName };
}
ifGenerationMatch: 0 is the cheap idempotency guard: generation zero means “no live object”, so a replayed request cannot overwrite a finished upload. It fails with 412 Precondition Failed instead, which is the signal to look up the existing row rather than re-upload. The same reasoning drives retrying fetch uploads with idempotency keys.
Driving the session from the browser
const CHUNK = 8 * 1024 * 1024; // 8 MiB — 32 × 262144
function parseCommitted(header: string | null): number {
if (!header) return 0; // no Range header means nothing is committed yet
const match = /bytes=0-(\d+)/.exec(header);
return match ? Number(match[1]) + 1 : 0;
}
async function queryOffset(sessionUri: string, total: number): Promise<number> {
const res = await fetch(sessionUri, {
method: "PUT",
headers: { "Content-Range": `bytes */${total}` },
});
if (res.status === 200 || res.status === 201) return total; // already finished
if (res.status !== 308) throw new Error(`session query failed: ${res.status}`);
return parseCommitted(res.headers.get("Range"));
}
export async function uploadResumable(
sessionUri: string,
file: File,
onProgress: (sentBytes: number) => void,
): Promise<void> {
let offset = await queryOffset(sessionUri, file.size);
onProgress(offset);
while (offset < file.size) {
const end = Math.min(offset + CHUNK, file.size);
const res = await fetch(sessionUri, {
method: "PUT",
headers: { "Content-Range": `bytes ${offset}-${end - 1}/${file.size}` },
body: file.slice(offset, end),
});
if (res.status === 308) {
offset = parseCommitted(res.headers.get("Range"));
onProgress(offset);
continue;
}
if (res.status === 200 || res.status === 201) {
onProgress(file.size);
return;
}
throw new Error(`chunk PUT failed: ${res.status} ${await res.text()}`);
}
}
file.slice(offset, end) allocates nothing — it returns a lazy view over the underlying blob, which is why this loop holds a 4 GB file in a few megabytes of heap. That mechanism is covered in slicing large files with Blob.slice. Wrap the fetch in a retry helper before you ship: a bare throw on the first transient 503 turns a recoverable blip into a failed upload, and the pattern to use is exponential backoff for failed chunks. Persist sessionUri alongside the file handle if you want the session to outlive a reload — see resuming uploads after network loss.
Choosing the chunk size
Chunk size is a trade between request overhead and re-send cost. Each PUT carries a TLS round trip and a fresh set of headers; each failure re-sends the whole in-flight chunk. Below 1 MiB the request count dominates and mobile radios never reach full throughput; above 32 MiB a single failure on a weak link can cost more than the chunking saved.
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
expires (POST policy) |
number | Date | required | Absolute expiry. V4 hard limit is 7 days; use 10 minutes. |
conditions |
array | [] |
Server-enforced constraints. Without them the credential is a blank cheque within the prefix. |
fields |
object | {} |
Extra form fields. Each one also needs an eq condition to be binding. |
virtualHostedStyle |
boolean | false |
Signs against BUCKET.storage.googleapis.com rather than the path-style host. |
bucketBoundHostname |
string | unset | Signs for a custom domain fronted by a load balancer. |
origin (resumable) |
string | unset | Binds the session to one origin; must match a CORS entry character for character. |
preconditionOpts.ifGenerationMatch |
number | unset | 0 means “only if the object does not exist” → 412 on replay. |
offset (resumable) |
number | 0 |
Resume a known session server-side instead of starting at zero. |
chunkSize (createWriteStream) |
number | unset | Server-side streaming buffer; must be a multiple of 262144. |
metadata.metadata |
object | {} |
Custom metadata for resumable uploads (the POST-policy equivalent is x-goog-meta-*). |
userProject |
string | unset | Bills operations to a different project on requester-pays buckets. |
Bucket CORS from the client library
If you manage infrastructure from Node rather than gcloud, the same library sets the bucket policy. Note Range in responseHeader — without it, the browser hides the offset header and every resume starts from zero.
export async function applyGcsCors(): Promise<void> {
await storage.bucket(BUCKET).setCorsConfiguration([
{
origin: ["https://app.example.com", "http://localhost:5173"],
method: ["PUT", "POST", "GET", "HEAD"],
responseHeader: ["Content-Type", "Range", "ETag", "x-goog-resumable"],
maxAgeSeconds: 3600,
},
]);
console.log("CORS applied to", BUCKET);
// Expected: 'CORS applied to my-uploads-bucket' with no thrown error.
}
Configuration gotchas
403 SignatureDoesNotMatch on the POST
The form sent a field the policy did not sign, or changed a value. The usual culprit is Content-Type: appending a File whose type is "" (common for files dragged from a Windows share) makes the browser omit the field entirely. Set it explicitly with new File([blob], name, { type }) before appending, and always append the file part last.
400 with “the number of bytes uploaded is required to be equal or greater than 262144”
Your chunk was not a multiple of 256 KiB. The full message is Invalid request. The number of bytes uploaded is required to be equal or greater than 262144, except for the final request. Round CHUNK down to Math.floor(bytes / 262144) * 262144 if it comes from configuration.
The resumable session 403s on the first chunk
The origin passed to createResumableUpload does not match any CORS origin entry. GCS pins the session to that string, and https://app.example.com with a trailing slash is not the same as without one. Send window.location.origin from the client and use it verbatim.
Progress jumps back to zero after every retry
res.headers.get("Range") returned null because the bucket does not expose it. The browser received the header and discarded it — nothing in the Network tab looks wrong, which is what makes this one expensive. Add Range to responseHeader and invalidate the preflight cache by lowering maxAgeSeconds.
Custom metadata silently disappears
Metadata keys must be prefixed x-goog-meta- in a POST policy, or nested under metadata.metadata in a resumable upload. A top-level userId field is accepted, ignored, and never stored. Validate the object after upload rather than assuming the write landed — the wider case for that is server-side file validation.
Abandoned sessions you cannot see
A resumable session that is never completed leaves no object and no listing entry, but it does hold a name reservation for a week. You cannot enumerate them and you cannot cancel them in bulk, so reconcile against your own upload rows instead. The equivalent AWS problem, where orphaned parts do cost money, is handled in expiring incomplete multipart uploads automatically.
Verification
Prove the credential is genuinely constrained rather than trusting the happy path. First, from the shell, confirm the size ceiling is enforced by Google:
# Ask your API for a ticket, then replay it with a body that is deliberately too large.
curl -s -X POST https://api.example.com/uploads/ticket \
-H 'content-type: application/json' \
-d '{"contentType":"image/png"}' > ticket.json
head -c 60000000 /dev/urandom > big.bin
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$(jq -r .url ticket.json)" \
$(jq -r '.fields | to_entries[] | "-F\(.key)=\(.value)"' ticket.json) \
-F "file=@big.bin"
# expected: 400 with <Details>Content-length exceeds upper bound on range</Details>
Then assert the stored object matches what you signed for:
export async function verifyUpload(objectName: string): Promise<void> {
const [metadata] = await storage.bucket(BUCKET).file(objectName).getMetadata();
console.log(metadata.size, metadata.contentType, metadata.crc32c, metadata.metadata);
// expected: 1048576 image/png rBFMlQ== { 'user-id': '42' }
if (Number(metadata.size) === 0) {
throw new Error(`empty object stored at ${objectName}`);
}
}
crc32c is returned base64-encoded and is computed by GCS over the stored bytes, which makes it the honest integrity check: compare it against a checksum the client computed before sending, as in computing file checksums in the browser with Web Crypto. A mismatch means a proxy rewrote the body in flight.
Frequently Asked Questions
Should I use a signed POST policy or a resumable session?
Use a POST policy for single-shot uploads under about 100 MB where you want Google to enforce size and content type for you. Use a resumable session for large media, for anything uploaded over mobile, and whenever a dropped connection should resume rather than restart. Resumable sessions cannot enforce a size ceiling, so pair them with a Content-Length check when you reconcile the finished object.
Can generateSignedPostPolicyV4 work without a JSON key file?
Yes, on any runtime with an attached service account. The library detects the missing private key and calls the IAM Credentials signBlob API instead, which adds roughly 40–80 ms per signature and requires roles/iam.serviceAccountTokenCreator on the service account itself. Cache nothing — each policy is single-use by design.
Why does my 308 response never appear in fetch?
Either you are reading res.redirected and assuming a redirect was followed, or the Range header is not in the bucket’s responseHeader list so the parsed offset is always zero. GCS sends 308 without a Location, so fetch returns it to you untouched; the header exposure is the part people miss.
Does a resumable session count against the object’s generation number?
No. The object does not exist until the final chunk is acknowledged with 200 or 201, so ifGenerationMatch: 0 stays satisfiable for the whole session. That also means a half-finished session is invisible to a list call and to bucket notifications, which is why your database — not the bucket — is the source of truth for in-flight uploads. The wider state model is in resumable upload state machines.
How do I stop one user issuing thousands of tickets?
Rate-limit at issuance, not at upload — the storage layer has no idea who your users are. Cap tickets per user per minute and record every issued object name so an unclaimed name can be reaped.