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 (or a resumable session URI), and the client uploads with no service-account key ever leaving the server. The @google-cloud/storage package does the signing in one method call.

This guide is part of Direct-to-Cloud Upload Patterns within Backend Validation & Cloud Storage Architecture. The provider trade-offs are compared in S3 vs GCS vs Azure Blob for media uploads.

When to use this approach

  • Your stack runs on Google Cloud and you want uploads to bypass your API server.
  • You want server-enforced constraints (size, prefix, content type) on a browser POST.
  • You need resumable uploads for large media that survive a dropped connection.

Prerequisites

  1. A GCS bucket and a service account with roles/storage.objectAdmin on it.
  2. npm i @google-cloud/storage on Node 20+.
  3. GOOGLE_APPLICATION_CREDENTIALS pointing at the service-account key, or workload identity.
  4. Bucket CORS allowing your frontend origin (see the end of this guide).

Implementation: a V4 signed POST policy

A signed POST policy lets the browser submit a multipart form directly to GCS while you enforce limits server-side. The policy is signed by the service account and expires quickly.

import { Storage } from "@google-cloud/storage";

const storage = new Storage();
const BUCKET = process.env.GCS_BUCKET!;

export interface UploadTicket {
  url: string;
  fields: Record<string, string>;
  objectName: string;
}

export async function createUploadTicket(
  userId: string,
  contentType: string,
): Promise<UploadTicket> {
  const allowed = ["image/png", "image/jpeg", "video/mp4"];
  if (!allowed.includes(contentType)) {
    throw new Error(`Unsupported content type: ${contentType}`);
  }

  const objectName = `uploads/${userId}/${crypto.randomUUID()}`;
  const file = storage.bucket(BUCKET).file(objectName);

  const [response] = await file.generateSignedPostPolicyV4({
    expires: Date.now() + 10 * 60 * 1000, // 10 minutes
    fields: { "x-goog-meta-user-id": userId },
    conditions: [
      ["content-length-range", 0, 50 * 1024 * 1024], // cap at 50 MB
      ["eq", "$Content-Type", contentType],
    ],
  });

  return { url: response.url, fields: response.fields, objectName };
}

Line-by-line on the critical parameters

  • generateSignedPostPolicyV4 returns a url plus a fields map. The browser must submit those exact fields, in a multipart form, ahead of the file part.
  • expires is an absolute timestamp in milliseconds. Keep it short — ten minutes is plenty for a single upload and limits the window a leaked policy is usable.
  • content-length-range is enforced by GCS itself; an oversized body is rejected at the storage layer, not in your code. This is your server-side size guard.
  • eq on $Content-Type pins the content type. The browser’s form must send a matching Content-Type field or the upload is refused.
  • x-goog-meta-user-id stores custom metadata on the object, useful for later metadata indexing and search.

The client then posts the form:

export async function uploadViaPolicy(ticket: UploadTicket, file: File) {
  const form = new FormData();
  for (const [key, value] of Object.entries(ticket.fields)) {
    form.append(key, value);
  }
  form.append("file", file);

  const res = await fetch(ticket.url, { method: "POST", body: form });
  if (!res.ok) {
    throw new Error(`GCS upload failed: ${res.status} ${await res.text()}`);
  }
  return ticket.objectName; // 204 No Content on success.
}

Resumable uploads for large media

For files large enough to risk a mid-flight disconnect, create a resumable session URI. The client uploads sequential byte ranges to one URI and can resume from the last acknowledged offset, which underpins a resumable upload state machine on the frontend.

GCS resumable upload flow The backend creates a resumable session URI, the browser PUTs byte ranges to it and resumes from the last offset after an interruption. Backend creates session session URI Browser PUT byte ranges GCS object one session URI interruption query offset, resume from there
A single resumable session URI accepts byte ranges and lets the client resume from the last acknowledged offset.
export async function createResumableSession(
  userId: string,
  contentType: string,
): Promise<{ uri: string; objectName: string }> {
  const objectName = `uploads/${userId}/${crypto.randomUUID()}`;
  const file = storage.bucket(BUCKET).file(objectName);

  const [uri] = await file.createResumableUpload({
    metadata: { contentType, metadata: { "user-id": userId } },
    origin: "https://app.example.com", // must match a CORS origin
  });

  return { uri, objectName };
}

Bucket CORS configuration

Direct browser uploads require the bucket to allow your origin. GCS uses responseHeader for what S3 calls exposed headers; expose ETag if you verify uploads client-side. The full cross-provider reference is in CORS configuration for uploads.

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", "ETag", "x-goog-resumable"],
      maxAgeSeconds: 3000,
    },
  ]);
  console.log("CORS applied");
  // Expected: "CORS applied" with no thrown error.
}

Configuration gotchas

“SignatureDoesNotMatch” on the POST

The browser form sent a field that was not in the signed policy, or a field value that differs (commonly a Content-Type mismatch). Submit every field from ticket.fields verbatim, before the file part, and make the form’s content type equal the one you pinned with eq $Content-Type.

Resumable upload returns 403 on the first chunk

The origin passed to createResumableUpload does not match any CORS origin entry. GCS binds the session to that origin; set it to the exact value of window.location.origin in the client.

Object lands but custom metadata is missing

Custom metadata keys must be prefixed x-goog-meta- in a POST policy, or nested under metadata.metadata in resumable uploads. A top-level userId field is silently ignored.

Verification

Confirm the object exists and carries the expected metadata after upload:

export async function verifyUpload(objectName: string) {
  const [metadata] = await storage.bucket(BUCKET).file(objectName).getMetadata();
  console.log(metadata.size, metadata.contentType, metadata.metadata);
  // Expected: byte size, the pinned content type, and { "user-id": "..." }.
}

FAQ

Should I use a signed POST policy or a resumable upload?

Use a signed POST policy for small-to-medium single-shot uploads where you want server-enforced size and type limits. Use a resumable session for large media where a dropped connection should resume rather than restart.

Can the browser hold my service-account key?

Never. The key stays on the server; only the short-lived signed policy or session URI reaches the browser. That is the entire point of the signed-upload model.

Do I need to expose ETag in CORS for GCS?

Only if your client reads it to verify the upload. GCS resumable uploads track progress by byte offset, not per-chunk ETags, so it is optional for resumability but useful for integrity checks.