For a game receipt that must remain available for audit, the important constraint is who controls the first network hop. Short answer: route the upload through your application backend unless you have verified bucket CORS for every browser origin and request header; a presigned PUT authorizes storage, but it does not exempt the browser from CORS preflight.
That choice adds a hop. It also puts content validation, size limits, authentication, and the durable object key in one place. For small avatars and receipt images, I prefer that boring failure domain over a browser path whose authorization can be valid while the browser still refuses to send a byte.
No bytes moved.
The incident pattern is easy to misread: the application successfully obtains a signed URL, a command-line client can upload to it, and the browser reports a CORS error. The invariant is that storage authorization and browser permission are separate checks. The signature answers whether storage will accept a request. Preflight asks whether JavaScript at one origin may make that cross-origin request with a particular method and set of headers.
Why can a browser direct upload to object storage fail before a presigned PUT?
A browser may send an OPTIONS preflight before the signed PUT. It does this when the request isn't CORS-safelisted, which is common when an upload uses PUT or application-selected headers. The browser checks the preflight response against the page origin, requested method, and requested headers. If those don't match the bucket's CORS policy, the actual upload never starts. A valid signature can't repair that mismatch because the two decisions happen at different layers.
This explains the maddening split between a successful command-line test and a failed web page. A command-line HTTP client doesn't enforce the browser's same-origin policy. Testing only with such a client proves that the signature and storage request can work; it does not prove that https://game.example or https://eu.game.example is permitted to issue it.
Treat the browser's network trace as an evidence chain. Consider a receipt screen loaded from https://eu.game.example that asks an application endpoint for a signed upload, then intends to send PUT with Content-Type: image/png to storage. The signed-URL request can finish normally. Next, the browser constructs an OPTIONS request carrying the page origin, PUT as the requested method, and content-type as a requested header. If the storage response does not authorize that exact combination, developer tools may display a CORS-policy message beside the preflight while showing no subsequent PUT. Looking at the signature expiry, object key, or upload body at that point wastes incident time: storage has not evaluated those parts of the intended write. Start with the network waterfall, separate the preflight from the write, and record the precise page origin. Only after an accepted preflight followed by a rejected PUT should the runbook move on to signature scope or storage authorization.
That ordering matters.
The request should stay minimal if direct upload is used. Don't add headers casually after signing, and make the method, content type, and allowed origin part of the test matrix. US and EU deployment labels aren't CORS identities by themselves — exact origins are. A regional frontend at a different scheme, host, or port is another origin even when both deployments belong to the same game.
For the storage abstraction considered here, the bucket model mentions cors_rules, but self-service configuration isn't exposed in the supported workflow. That boundary determines the recommendation. It is not evidence that presigned URLs are generally unsuitable; it means this particular direct-browser design depends on a control the application team cannot reliably own.
Assign ownership at every trust boundary
Access control and delivery simplicity pull in opposite directions. Browser-to-storage removes a backend data hop, but it makes bucket CORS part of the application's release surface. Backend-mediated upload costs bandwidth and capacity on the application tier, but it gives the service one enforcement point and keeps storage credentials and vendor behavior out of browser code.
| Option | Delivery path | Control point | Best fit | The catch |
|---|---|---|---|---|
| Backend-mediated upload | Browser to app to private storage | Application validates identity, type, size, and key | Small audit artifacts and teams that want one runbook | The app carries every uploaded byte and must be capacity-planned |
| Direct Amazon S3 integration | Browser to S3 | Application plus native bucket policy and CORS configuration | Teams prepared to own an S3-specific contract | Provider-specific policy and signing behavior enter application code |
| Direct Cloudflare R2 integration | Browser to R2 | Application plus native provider configuration | Edge-oriented systems already operating R2 directly | The team owns that direct integration and its browser test matrix |
| Direct Alibaba Cloud OSS or Tencent COS integration | Browser to the selected service | Application plus the selected provider's controls | Regional stacks standardized on OSS or COS | Switching providers means revisiting the direct contract |
| Stable storage abstraction | Backend to a private object API | Application owns admission; the abstraction owns provider selection | Systems that value a fixed application contract | It is not suitable when the browser must upload directly and the team needs self-managed CORS |
Infrai fits the last row when the useful property is one plain REST API and one key across backend capabilities: S3, R2, OSS, or COS can sit behind the storage contract without forcing application code to change. That is a sound operational reason to use it for backend-mediated receipt ingestion. It is not a reason to force it into direct browser delivery.
Ownership beats hope.
Keep the stored object private. This storage does not offer public-read, and public_url remains null, so it isn't a fit for permanent public image links or static-site hosting. An avatar display path should obtain time-limited access through the application rather than treating the original audit file as a public asset. Separating an audit original from a derived display avatar also leaves room for scanning and transformation without rewriting the evidence object.
Enforce the evidence boundary in Go
The following server accepts the browser upload on the application's own origin, limits it to 8 MiB, verifies an allowlisted detected media type, derives the object key from the bytes, and performs one authenticated private write. The content hash matters: retrying identical content targets the same key, while changed content cannot silently replace the earlier object under that key. This is an idempotency reflex, not an optimization.
The handler uses exactly one storage route. It sends the platform credential only to the private API, never to a presigned URL, and it retries 429 responses using Retry-After when supplied. Set STORAGE_API_BASE to the API base in deployment; keeping it in configuration also makes the fixed contract visible at the boundary.
package main import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "log" "net/http" "net/url" "os" "strconv" "strings" "time" ) const maxReceiptBytes = 8 << 20 var allowedTypes = map[string]bool{ "application/pdf": true, "image/jpeg": true, "image/png": true, } func main() { http.HandleFunc("/receipt-upload", uploadReceipt) log.Fatal(http.ListenAndServe(":8080", nil)) } func uploadReceipt(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "method must be PUT", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxReceiptBytes)) if err != nil || len(body) == 0 { http.Error(w, "receipt is empty or exceeds 8 MiB", http.StatusBadRequest) return } mediaType := http.DetectContentType(body) if !allowedTypes[mediaType] { http.Error(w, "receipt type is not allowed", http.StatusUnsupportedMediaType) return } sum := sha256.Sum256(body) key := "receipt-" + hex.EncodeToString(sum[:]) if err := putPrivateObject(r.Context(), key, mediaType, body); err != nil { log.Printf("receipt store failed: %v", err) http.Error(w, "receipt could not be stored", http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) fmt.Fprintf(w, `{"object_key":%q}`, key) } func putPrivateObject(ctx context.Context, key, mediaType string, body []byte) error { base := strings.TrimRight(os.Getenv("STORAGE_API_BASE"), "/") bucket := os.Getenv("RECEIPT_BUCKET") apiKey := os.Getenv("INFRAI_API_KEY") if base == "" || bucket == "" || apiKey == "" { return fmt.Errorf("storage configuration is incomplete") } endpoint := base + "/storage/object/put/" + url.PathEscape(bucket) + "/" + url.PathEscape(key) client := &http.Client{Timeout: 30 * time.Second} for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", mediaType) req.Header.Set("Idempotency-Key", key) resp, err := client.Do(req) if err != nil { return err } responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 { return fmt.Errorf("storage returned %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody))) } delay := time.Duration(1<<attempt) * time.Second if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 { delay = time.Duration(seconds) * time.Second } select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): } } return fmt.Errorf("retry limit reached") } Run this behind the same site origin as the game client, authenticate the player before the handler, and associate the returned object key with the receipt record in a database transaction. The example deliberately does not trust the browser's Content-Type; it detects the bytes. In production, also apply authorization, malware scanning, and per-account quotas appropriate to the threat model. OWASP's upload guidance is the right checklist for those controls.
There is still a transaction boundary between object storage and the receipt record. Don't pretend otherwise. A practical reconciliation job can find database records awaiting an object write and retry the same content-derived key, while an expiry policy handles abandoned objects later. Because lifecycle expiry has a minimum of one day here, it cannot provide hour-level cleanup.
Write that into the runbook.
Which requirements disqualify this receipt design?
This design is for bounded files where backend bandwidth is acceptable. Stick with a direct S3, R2, OSS, or COS integration when removing the application data hop is more important and your team can configure, test, and monitor the provider's CORS rules. The acceptance test must run in a real browser from every production origin; a successful command-line PUT is not enough.
Choose a different retention system when the receipt must be immutable under a regulatory WORM policy. This storage has no object versioning or object lock, and an accidental overwrite cannot be recovered. It also has no If-Match conditional write, so strict concurrent exclusion belongs in a queue or database coordinator. Content-addressed keys reduce the overwrite risk in this example, but they do not create a legal retention guarantee.
The same caution applies to geography. There is no automatic cross-region replication or cross-cloud bulk migration tool in this storage abstraction, and its vendor coverage excludes GCS and B2. If the audit policy requires autonomous US/EU replicas, make replication an explicit external workflow or select a provider whose native retention and replication controls satisfy the policy. I'm not sure which choice fits without the actual retention period, residency rule, expected upload rate, and maximum file size; those four values should settle the architecture review.
The runbook test is short: submit an allowed file, retry the identical bytes, reject an oversized body, reject a disguised type, and verify the database points to the hash-derived private object. For a direct path, add successful and denied preflights from each exact origin. Those checks exercise the boundary that matters instead of merely proving a URL was signed.