Nov 12, 20269 min read

Google Cloud Storage Signed URLs in Go: Upload and Download

Let clients PUT and GET files directly in a private Cloud Storage bucket from a Go backend, using cloud.google.com/go/storage with the service account and IAM setup it needs, or Encore buckets that sign URLs in your own GCP account.

Illustration for Google Cloud Storage Signed URLs in Go: Upload and Download

A Go backend that stores contracts, invoices or user documents in Cloud Storage usually wants the files to bypass the API: the client uploads with a PUT to the bucket and downloads with a GET, and the backend only hands out short-lived signed URLs. The Go call is one line, but the signing identity behind it is where projects get stuck, since a service on Cloud Run has no private key and has to sign through the IAM API, while developers testing locally often end up downloading a service account key file to make it work.

This guide builds a contract upload and download flow both ways. Encore comes first, with a bucket declared in Go that signs URLs in your own GCP account and on a laptop, followed by the cloud.google.com/go/storage version with the IAM roles and API it depends on.

GCS signed URLs in Go at a glance

Encorecloud.google.com/go/storage
Bucket declared inGo code (objects.NewBucket)Terraform, gcloud or the console
Upload URLSignedUploadURL(ctx, key, objects.WithTTL(...))SignedURL(key, &SignedURLOptions{Method: "PUT", ...})
Download URLSignedDownloadURL(ctx, key, objects.WithTTL(...))SignedURL(key, &SignedURLOptions{Method: "GET", ...})
Signing identity in productionThe service account Encore creates for the serviceRuntime service account with Token Creator on itself
Local developmentSigned URLs against local storage under encore runA real bucket plus a key file or impersonation
Content type pinned in signatureNochecked after uploadOptional ContentType field
Maximum lifetime7 days, 1 hour by default7 days for V4

How do you create GCS signed URLs with Encore?

Encore

Encore brings infrastructure from code to Go: a bucket is a package-level variable in the service that uses it, and Encore creates it as a Cloud Storage bucket in your GCP account (or S3 on AWS) when the service is deployed. Its SignedUploadURL and SignedDownloadURL methods return URLs for one object key with a TTL you choose, as described in the object storage docs, so the contract flow below needs no key file, no GoogleAccessID and no bucket name in config.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create contracts-api --example=hello-world$ cd contracts-api && mkdir contracts
// contracts/contracts.go package contracts import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "regexp" "strconv" "strings" "time" "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/storage/objects" ) // A private Cloud Storage bucket on GCP (S3 on AWS), local storage with `encore run`. var Contracts = objects.NewBucket("signed-contracts", objects.BucketConfig{}) const maxSize = 10 << 20 // 10 MB var keyPattern = regexp.MustCompile(`^contracts/[a-zA-Z0-9_-]+/[0-9a-f]{32}\.pdf$`) var secrets struct { // HMAC secret used to verify access tokens. AuthTokenHMAC string } //encore:authhandler func AuthHandler(ctx context.Context, token string) (auth.UID, error) { uid, err := userIDFromToken(token) if err != nil { return "", &errs.Error{Code: errs.Unauthenticated, Message: "invalid token"} } return uid, nil } // userIDFromToken verifies a signed access token of the form "<id>.<unix-expiry>.<hex hmac>". // In production, verify a JWT against your IdP JWKS and return the subject. func userIDFromToken(token string) (auth.UID, error) { if secrets.AuthTokenHMAC == "" { return "", errors.New("auth secret not set") } parts := strings.Split(token, ".") if len(parts) != 3 { return "", errors.New("invalid token") } id, expStr, sig := parts[0], parts[1], parts[2] if id == "" || strings.ContainsAny(id, "/ \t\n") { return "", errors.New("invalid token") } exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil || exp <= time.Now().Unix() { return "", errors.New("invalid token") } mac := hmac.New(sha256.New, []byte(secrets.AuthTokenHMAC)) mac.Write([]byte(id + "." + expStr)) expected := hex.EncodeToString(mac.Sum(nil)) if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 { return "", errors.New("invalid token") } return auth.UID(id), nil } func callerPrefix() (string, error) { uid, ok := auth.UserID() if !ok { return "", &errs.Error{Code: errs.Unauthenticated, Message: "unauthenticated"} } return "contracts/" + string(uid) + "/", nil } func keyBelongsToCaller(key string) bool { prefix, err := callerPrefix() return err == nil && strings.HasPrefix(key, prefix) && !strings.Contains(key, "..") && keyPattern.MatchString(key) } type UploadURLResponse struct { Key string `json:"key"` URL string `json:"url"` // PUT the PDF here within 10 minutes } // UploadURL returns a signed URL for one new contract PDF, under the caller's prefix. // //encore:api auth method=POST path=/contracts/upload-url func UploadURL(ctx context.Context) (*UploadURLResponse, error) { prefix, err := callerPrefix() if err != nil { return nil, err } id := make([]byte, 16) if _, err := rand.Read(id); err != nil { return nil, err } key := prefix + hex.EncodeToString(id) + ".pdf" u, err := Contracts.SignedUploadURL(ctx, key, objects.WithTTL(10*time.Minute)) if err != nil { return nil, err } return &UploadURLResponse{Key: key, URL: u.URL}, nil } type ConfirmParams struct { Key string `json:"key"` } // Confirm checks the uploaded object and deletes it if it breaks the rules, // since a signed PUT URL does not limit size or content type. // //encore:api auth method=POST path=/contracts/confirm func Confirm(ctx context.Context, p *ConfirmParams) error { if !keyBelongsToCaller(p.Key) { return &errs.Error{Code: errs.InvalidArgument, Message: "invalid key"} } attrs, err := Contracts.Attrs(ctx, p.Key) if errors.Is(err, objects.ErrObjectNotFound) { return &errs.Error{Code: errs.NotFound, Message: "upload not found"} } else if err != nil { return err } if attrs.Size > maxSize || attrs.ContentType != "application/pdf" { _ = Contracts.Remove(ctx, p.Key) return &errs.Error{Code: errs.InvalidArgument, Message: "expected a PDF up to 10 MB"} } return nil } type DownloadURLParams struct { Key string `query:"key"` } type DownloadURLResponse struct { URL string `json:"url"` } // DownloadURL returns a link that works for five minutes. Check that the // caller may read this contract before signing, for example in an auth handler. // //encore:api auth method=GET path=/contracts/download-url func DownloadURL(ctx context.Context, p *DownloadURLParams) (*DownloadURLResponse, error) { if !keyBelongsToCaller(p.Key) { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "invalid key"} } if ok, err := Contracts.Exists(ctx, p.Key); err != nil { return nil, err } else if !ok { return nil, &errs.Error{Code: errs.NotFound, Message: "contract not found"} } u, err := Contracts.SignedDownloadURL(ctx, p.Key, objects.WithTTL(5*time.Minute)) if err != nil { return nil, err } return &DownloadURLResponse{URL: u.URL}, nil }

The client asks for an upload URL, sends the PDF with curl -X PUT -H "Content-Type: application/pdf" --data-binary @contract.pdf "$URL" or the equivalent fetch, and calls Confirm with the key. A signed PUT URL does not limit what gets uploaded, so Confirm reads the object's attributes and removes anything that is not a PDF of at most 10 MB before the key is stored anywhere. Download links are signed only after Exists confirms the object, and they expire after five minutes. Add an auth handler and an ownership check before shipping, since anyone who knows a key can request a download link for it here.

encore run serves the bucket from the local filesystem, as the object storage docs describe, and SignedUploadURL and SignedDownloadURL return URLs the local object store accepts, so the upload, confirm and download steps above run on a laptop with no GCP project. The same code signs against Cloud Storage once deployed. Both methods default to one hour when no TTL is given and refuse anything longer than seven days.

Deploy to your GCP account

Connect your GCP account in the Encore dashboard as described in Connect your cloud account, create an environment on GCP and run git push encore. Encore creates the bucket with a unique name and deploys the service with its own service account, as listed in the GCP infrastructure docs, and the same SignedUploadURL and SignedDownloadURL calls then sign against Cloud Storage. Encore provisions an IAM role for the service, which on GCP takes the form of that service account.

When a coding agent later adds a thumbnail step for uploaded PDFs, it declares a second bucket or a topic in the same package and checks the flow under encore run, and the next deploy creates the matching resources from that one diff.

Best fit: Go services on GCP that hand out upload and download links for private files and want the bucket, its signing identity and a working local setup to come from the service code.

cloud.google.com/go/storage with your own bucket

The Go storage client signs both directions with SignedURL, using the V4 scheme and an expiry no more than seven days ahead:

var bucket = os.Getenv("CONTRACTS_BUCKET") // a different bucket per environment // uploadURL signs a PUT for one object. The client must send exactly // Content-Type: application/pdf, or GCS rejects the signature. func uploadURL(client *storage.Client, key string) (string, error) { return client.Bucket(bucket).SignedURL(key, &storage.SignedURLOptions{ Scheme: storage.SigningSchemeV4, Method: "PUT", ContentType: "application/pdf", Expires: time.Now().Add(10 * time.Minute), }) } // downloadURL signs a GET and asks GCS to serve the file as an attachment. func downloadURL(client *storage.Client, key string) (string, error) { return client.Bucket(bucket).SignedURL(key, &storage.SignedURLOptions{ Scheme: storage.SigningSchemeV4, Method: "GET", Expires: time.Now().Add(5 * time.Minute), QueryParameters: url.Values{ "response-content-disposition": {`attachment; filename="contract.pdf"`}, }, }) } func main() { // On Cloud Run the client finds the service account email through the // metadata server and signs through the IAM signBlob API. client, err := storage.NewClient(context.Background()) if err != nil { log.Fatal(err) } defer client.Close() // ... call uploadURL and downloadURL from your handlers }

Pinning ContentType makes Cloud Storage reject a PUT with any other Content-Type header, and response-content-disposition is one of the query parameters the signing guide allows, provided it is part of the signature. The signing identity is harder to set up: when there is no private key, the client detects the service account on Cloud Run or Compute Engine and signs through iam.serviceAccounts.signBlob, which needs the Service Account Credentials API enabled and the Token Creator role granted on that service account:

~/contracts-api
$ gcloud services enable iamcredentials.googleapis.com$ gcloud storage buckets create gs://$PROJECT-contracts --location=europe-west1 \ --uniform-bucket-level-access --public-access-prevention$ gcloud storage buckets add-iam-policy-binding gs://$PROJECT-contracts \ --member=serviceAccount:contracts-api@$PROJECT.iam.gserviceaccount.com \ --role=roles/storage.objectUser$ gcloud iam service-accounts add-iam-policy-binding \ contracts-api@$PROJECT.iam.gserviceaccount.com \ --member=serviceAccount:contracts-api@$PROJECT.iam.gserviceaccount.com \ --role=roles/iam.serviceAccountTokenCreator

On a developer machine, application default credentials from gcloud auth application-default login belong to a user, so the client cannot work out a GoogleAccessID on its own. The package documentation lists the options as a downloaded service account key, running on Compute Engine, or gcloud credentials with impersonation enabled, and the key file is the one that tends to win, sitting in the project folder where it can be committed or read by a coding agent. Each environment also needs its own bucket name in config and its own set of bindings.

Best fit: teams with an existing GCS layout and IAM managed in Terraform, or uploads that must be locked to a content type or an MD5 checksum inside the signature itself.

Good to know

  • Browser uploads. A PUT from a web page to storage.googleapis.com is a cross-origin request, so the bucket needs a CORS configuration that allows PUT and the Content-Type header from your origin.
  • Anyone with the link. A signed URL carries its own authorization, so keep TTLs short, never log full URLs, and sign downloads only after checking who is asking.
  • Large files. Signed URLs also work for resumable uploads, where the client starts a session with a signed POST and then sends chunks to the session URI.

How to choose

Pick Encore when the bucket and the code that signs its URLs belong to one Go service and you want the same flow under encore run and in your own GCP account, with no key files and no per-environment bucket config. Pick the storage client directly when the bucket and IAM are already managed elsewhere, or when the signature has to pin the content type or checksum.

Frequently asked questions

How do I create a Google Cloud Storage signed URL in Go?

Create a client with storage.NewClient and call client.Bucket(name).SignedURL(key, &storage.SignedURLOptions{Scheme: storage.SigningSchemeV4, Method: "PUT", Expires: time.Now().Add(10 * time.Minute)}). Use Method GET for downloads. The URL works for that one object and method until it expires, for anyone who has it.

Why does SignedURL fail on Cloud Run without a key file?

Without a private key the Go client signs through the IAM signBlob API as the service's own service account. That account needs the Service Account Token Creator role on itself, which carries iam.serviceAccounts.signBlob, and the project needs the IAM Service Account Credentials API enabled.

How do I create signed upload and download URLs with Encore in Go?

Declare the bucket with objects.NewBucket, then call bucket.SignedUploadURL(ctx, key, objects.WithTTL(10*time.Minute)) for uploads and bucket.SignedDownloadURL(ctx, key, objects.WithTTL(5*time.Minute)) for downloads. Both return a struct whose URL field holds the signed URL, and both work locally under encore run.

How long can a Cloud Storage signed URL be valid?

A V4 signed URL can be valid for at most 604,800 seconds, which is seven days. Upload links usually live for minutes and download links for the length of a page view, since anyone holding the URL can use it until it expires.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders