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

  1. 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).
  2. A bucket with uniform bucket-level access enabled. Object ACLs are a legacy path and interact badly with signed uploads.
  3. A service account holding roles/storage.objectCreator on the bucket, plus roles/storage.objectViewer if the same identity runs the verification step.
  4. Credentials via GOOGLE_APPLICATION_CREDENTIALS pointing at a JSON key, or workload identity plus roles/iam.serviceAccountTokenCreator granted to the service account on itself — that second binding is what allows keyless signing.
  5. Bucket CORS allowing your frontend origin and exposing the Range header. 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.

Anatomy of a V4 signed POST policy A policy document of conditions is signed with the service account key and returned as a flat map of form fields that the browser replays to Google Cloud Storage. What generateSignedPostPolicyV4 produces Policy document — server only expiration: now + 10 min conditions: content-length-range 0–50MB eq $Content-Type image/png eq $key uploads/u42/a7f1 eq $x-goog-meta-user-id 42 base64 of this JSON is signed RSA-SHA256 local key file or IAM signBlob signs the policy Fields returned to the browser key x-goog-algorithm x-goog-credential x-goog-date x-goog-signature policy (base64) replay all six, then the file GCS re-derives the signature from the replayed fields and evaluates each condition against the request it received. One altered byte in any field returns 403 SignatureDoesNotMatch; a condition that fails returns 400 with the offending policy line quoted in the XML body.
The policy is the contract: whatever is not pinned by a condition is whatever the client decides to send.

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

  • expires is 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 with Policy expired. Re-issue on demand rather than lengthening the window.
  • content-length-range takes a minimum as well as a maximum. Setting the floor to 1 rather than 0 blocks 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.
  • eq on $Content-Type pins the type. Note the dollar-prefixed form: conditions reference form fields, and omitting the $ silently creates a condition that can never match.
  • fields are copied into the response map and simultaneously constrained. Anything you put here must also appear in conditions as an eq — otherwise a client can drop the field and still pass signature verification, because unconstrained extras are permitted.
  • x-goog-meta-user-id becomes 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 PUT with Content-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 Location header, so fetch will not follow it as a redirect — you get the response object back and can read Range from it, provided the bucket’s CORS config exposes that header.
Resumable session message sequence Sequence of messages between backend, browser and Google Cloud Storage showing a chunk write, an interruption, an offset query returning 308, and the resumed upload completing with 200. Backend Browser GCS session resumable session URI PUT bytes 0-8388607/524288000 308, Range: bytes=0-8388607 network drops mid-chunk PUT Content-Range: bytes */524288000 308, Range: bytes=0-16777215 PUT from 16777216 → 200 OK
The 308 response is the source of truth for progress — never trust the client's own byte counter after a failure.

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.

Chunk size versus request count for a 500 MB upload Bar chart on a logarithmic scale showing that a 500 MB file needs 2000 requests at 256 KiB chunks but only 8 at 64 MiB, while the bytes re-sent by a single failure grow in step. Chunk size vs request count, 500 MB file (log scale) 2000 PUTs 500 PUTs 63 PUTs 16 PUTs 8 PUTs 256 KiB 1 MiB 8 MiB 32 MiB 64 MiB 0.25 MiB 1 MiB 8 MiB 32 MiB 64 MiB Second row: bytes a single failed chunk must re-send. 8 MiB is the default worth keeping. Every chunk but the last must be a multiple of 262144 bytes.
Request count falls logarithmically while retry cost rises linearly — 8 MiB sits at the knee for consumer connections.

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.