Direct-to-S3 / Azure tus 1.0 resume IndexedDB Golden Retriever 30 locales Webcam & screen capture

Direct-to-GCS uploads from ASP.NET Core

S3 and Azure get most of the attention. Google Cloud Storage is arguably the easier of the three to upload to directly — one resumable session instead of a multipart sequence — and this is the whole flow.

Who does what

Three parties, and the split is the point: your server signs, the browser transfers, your server records. The file itself never passes through Kestrel, so a 4 GB upload does not occupy a request thread, does not touch MultipartBodyLengthLimit, and does not care what your reverse proxy thinks about body size.

  1. The browser asks your app to start an upload.
  2. Your app calls GCS, opens a resumable session, and returns the session URI.
  3. The browser PUTs the bytes to that URI, in chunks, directly to Google.
  4. Your app is told when it finished, and records whatever it needs to.
Why the server opens the session

This is the part that catches people out. A signed URL for a resumable upload is not signed for PUT — it is signed for POST, and the request that opens the session must also carry the header x-goog-resumable: start. Sign it without that header, or send it with the wrong verb, and Google rejects the signature rather than telling you what is wrong.

Doing that in the browser would also mean shipping something that can mint upload URLs to every visitor. Both problems disappear if the session is opened server-side, which is why IGcsSigner is shaped the way it is: the browser never signs anything, and never sees a credential.

The interface you implement

CoreUpload ships no Google SDK. You register a signer, and it is four methods:

public interface IGcsSigner
{
    Task<GcsCreateResult>   CreateUploadAsync(GcsCreateRequest request, CancellationToken ct = default);
    Task<GcsSignResult>     SignChunkAsync(GcsSignRequest request, CancellationToken ct = default);
    Task<GcsCompleteResult> CompleteUploadAsync(GcsCompleteRequest request, CancellationToken ct = default);
    Task                    AbortUploadAsync(GcsAbortRequest request, CancellationToken ct = default);
}

builder.Services.AddSingleton<IGcsSigner, MyGcsSigner>();

CreateUploadAsync is the one that matters: it returns a SessionUri, an ObjectName and an UploadId. Without a signer registered the endpoint answers 501 GCS signer not registered rather than failing obscurely later.

The wire protocol
ExchangeWhat happens
POST /api/upload/gcs/initiateYour server opens the session; responds { sessionUrl, key }.
PUT <sessionUrl>Browser sends a chunk with Content-Range: bytes <start>-<end>/<total>, straight to Google.
308 Resume IncompleteThe answer to every chunk but the last. Range: bytes=0-N reports what GCS has committed.
200 / 201The final chunk. The body is the GCS object metadata.
POST /api/upload/gcs/finalizeOptional bookkeeping on your side once the object exists.

That 308 is what makes resuming free. After a dropped connection the client sends Content-Range: bytes */<total>, GCS answers with the offset it already holds, and the upload continues from there — no bytes re-sent, and no state kept on your server.

The bucket rule people forget

Because the browser talks to Google rather than to you, the bucket has to allow it. Without this CORS rule the first PUT fails and the browser console blames CORS, not the upload:

[{
  "origin":         ["https://your-app.example"],
  "method":         ["PUT", "OPTIONS"],
  "responseHeader": ["Range", "Content-Range", "ETag"],
  "maxAgeSeconds":  3600
}]

Range and Content-Range must be exposed or the client cannot read how far GCS got, which breaks resuming specifically — uploads appear to work until one is interrupted.

Four ways this goes wrong

Direct-to-cloud failures are unusually opaque, because the failing request is one your server never sees. These are the ones worth recognising on sight:

SymptomCause
CORS error on the first PUTThe bucket has no CORS rule, or its origin does not match yours exactly — scheme and port included.
Uploads work, resuming does notCORS is present but does not expose Range, so the client cannot read the committed offset back.
403 SignatureDoesNotMatchThe session was opened without x-goog-resumable: start, or signed for the wrong verb.
400 partway throughA chunk that is not the last one is not a multiple of 256 KiB — GCS requires that, and only the final chunk may be any size.

That last constraint is the one people trip over when hand-rolling this. The GCS strategy rounds whatever chunkSize you configure down to the nearest 256 KiB before it sends anything, so an awkward number is corrected rather than rejected mid-upload — but it is worth knowing the rule exists, because it means your configured chunk size and the one on the wire are not always the same number.

What your server still owns

Bytes bypassing your server does not mean control does. Every upload still begins with a request you authorise, so the decisions stay yours:

  • Who may upload. /gcs/initiate is an ordinary endpoint in your app — put your authentication and authorisation in front of it and an unauthenticated caller never gets a session URI.
  • Where it lands. Your signer chooses the object name, so users cannot pick their own paths or overwrite each other.
  • How big. The declared size arrives before the session is opened, so quota checks happen before a single byte moves rather than after.
  • What happens next. /gcs/finalize is where a database row, a thumbnail job or a virus scan gets queued.

A session URI is also short-lived and specific to one object, which limits what a leaked one is worth — unlike a broadly-scoped credential shipped to the browser.

GCS or S3?
  • Simpler protocol. One resumable session, versus the S3 initiate / sign N parts / complete sequence.
  • Egress. If the data is headed for BigQuery, Vertex AI or Cloud Run, same-region transfer is free.
  • Versioning and soft delete are first-class on GCS buckets.
  • For S3-compatible stores — MinIO, R2, B2, Wasabi — use the S3 strategy instead.