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
- A GCS bucket and a service account with
roles/storage.objectAdminon it. npm i @google-cloud/storageon Node 20+.GOOGLE_APPLICATION_CREDENTIALSpointing at the service-account key, or workload identity.- 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
generateSignedPostPolicyV4returns aurlplus afieldsmap. The browser must submit those exact fields, in a multipart form, ahead of the file part.expiresis 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-rangeis enforced by GCS itself; an oversized body is rejected at the storage layer, not in your code. This is your server-side size guard.eqon$Content-Typepins the content type. The browser’s form must send a matchingContent-Typefield or the upload is refused.x-goog-meta-user-idstores 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.
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.