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.
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.
- The browser asks your app to start an upload.
- Your app calls GCS, opens a resumable session, and returns the session URI.
- The browser
PUTs the bytes to that URI, in chunks, directly to Google. - Your app is told when it finished, and records whatever it needs to.
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.
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.
| Exchange | What happens |
|---|---|
POST /api/upload/gcs/initiate | Your 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 Incomplete | The answer to every chunk but the last. Range: bytes=0-N reports what GCS has committed. |
200 / 201 | The final chunk. The body is the GCS object metadata. |
POST /api/upload/gcs/finalize | Optional 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.
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.
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:
| Symptom | Cause |
|---|---|
CORS error on the first PUT | The bucket has no CORS rule, or its origin does not match yours exactly — scheme and port included. |
| Uploads work, resuming does not | CORS is present but does not expose Range, so the client cannot read the committed offset back. |
403 SignatureDoesNotMatch | The session was opened without x-goog-resumable: start, or signed for the wrong verb. |
400 partway through | A 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.
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/initiateis 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/finalizeis 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.
- 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.