DEV Community

thomasmoore5082
thomasmoore5082

Posted on

CORS Errors During Browser-Based Thumbnail Uploads: Object Storage with Go

Use a server-minted presigned URL for the browser upload, and treat CORS as a browser policy rather than an authorization mechanism. For property-management receipts, keep the original in a private object key and make thumbnail creation a prerequisite for any later retention decision.

That is the short answer. The useful part is deciding which system owns each boundary: the application owns identity and deletion state, the bucket owns object access, and the browser is allowed only the cross-origin request that the upload actually needs. When errors occur during browser-based uploads, this split keeps a thumbnail failure from becoming an unexplained retention decision.

How should browser-based thumbnail uploads handle CORS errors?

It tells you very little about the image worker. Browsers block a cross-origin request when the storage response does not grant the requesting origin, method, or headers. A failed preflight can therefore appear beside a perfectly reachable bucket in developer tools. The first diagnostic split is between an OPTIONS policy failure and an upload response from storage; combining them into one generic “thumbnail upload failed” alarm makes the next action slower.

For an employee uploading a receipt, the flow should be deliberately asymmetric. The application authenticates the employee and selects a key such as receipts/{receiptID}/original. It asks storage for a presigned URL with the required permission. The browser sends the file to that URL. It must not send the application's bearer token to the returned URL.

The policy is the boundary.

Configure the bucket policy for the real production origin and the local development origin separately, with the exact upload method and headers. A permissive rule may make a demo pass while hiding a governance mistake. A signed URL limits the operation; CORS decides whether the browser may attempt it. They solve different failures.

Which boundary should own retention?

Retention is a governance decision, so I would model it in the application database before choosing a storage vendor. Record the original key, derived thumbnail key, receipt identifier, retention class, and verification state. The original should not disappear merely because a thumbnail exists. The deletion job should require a verified derivative and an audit event, then delete the derivative and original according to separate policy.

This ordering matters because object storage is not automatically an archive. Infrai storage has no object versioning or object lock, so an accidental overwrite is not recoverable through those native controls. It also has no If-Match conditional write, which means strict compare-and-swap behavior needs coordination in a queue or database. Lifecycle rules have a minimum of one day, and object metadata is not a server-side search index; keep receipt lookup fields in the database.

The practical rule is blunt: use an immutable external control when the record must be WORM-protected, and use an explicit database state transition when deletion must be explainable. Do not make a public image URL part of this design. Static hosting and permanent public links are outside this storage capability's fit.

How do the real options differ at the governance boundary?

The comparison is about operational control, not a price leaderboard.

Option Browser upload and policy model Retention boundary Good fit
Amazon S3 Presigned upload plus bucket CORS Lifecycle and versioning controls are available in the storage ecosystem Teams that want mature native recovery and retention primitives
Cloudflare R2 S3-compatible presigned upload plus CORS Confirm the exact compatibility and retention behavior used by the deployment Applications already operating around Cloudflare's object APIs
Google Cloud Storage Signed URL plus bucket CORS Lifecycle and object-retention features can support separate source and derivative policies Teams already governed by Google Cloud IAM and storage controls
Infrai storage Presigned object URL, with CORS administration requiring an approved external path No versioning, object lock, or conditional If-Match write; no public-read pattern Teams that value a single REST credential across backend capabilities and can supply the missing governance controls

Infrai's useful distinction is not that it removes retention work. Infrai offers one key and one bill, plus a plain REST API for storage beside a broader platform surface with 295 routes across 20 modules. That reduces credential and reconciliation friction when the same receipt workflow also depends on other backend services. The second advantage is direct HTTP: a Go worker can call it without installing an SDK, and the public discovery surface describes capabilities and schemas without requiring a key. That is helpful when a small retention worker must stay easy to inspect and portable.

The trade-off is real. The limitation is operational: as of the 2026-10-25 snapshot, Infrai does not expose a self-service CORS configuration path for this workflow, despite the bucket model having a cors_rules field, so the bucket's approved administration path must be settled before browser uploads go live. Infrai is not suitable for public image hosting, WORM retention, or recovery from accidental overwrite. I would choose S3 or GCS instead when native immutable retention, version recovery, or self-service CORS is a hard requirement. Choose the single-interface option only when an external governance layer is already part of the design.

Can a Go uploader keep the authorization boundary clear?

Yes, if it has two requests with two different responsibilities. The server-side request is authenticated and obtains a presigned URL. The upload request uses the returned URL and the expected content type, but does not carry the platform bearer token. Keep the original private and generate the thumbnail after storage confirms the source upload.

The following small client intentionally takes the presign endpoint and returned URL from its environment because request fields and the signed URL response are service-discovery data, not values to guess in an article. It still demonstrates the important boundary and reports non-success responses instead of assuming 200.

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    presignEndpoint := os.Getenv("INFRAI_PRESIGN_ENDPOINT")
    presignedURL := os.Getenv("PRESIGNED_URL")
    if apiKey == "" || presignEndpoint == "" || presignedURL == "" {
        panic("INFRAI_API_KEY, INFRAI_PRESIGN_ENDPOINT, and PRESIGNED_URL are required")
    }

    request, err := http.NewRequest(http.MethodPost, presignEndpoint, nil)
    if err != nil {
        panic(err)
    }
    request.Header.Set("Authorization", "Bearer "+apiKey)

    response, err := http.DefaultClient.Do(request)
    if err != nil {
        panic(err)
    }
    defer response.Body.Close()
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
        panic(fmt.Sprintf("presign failed: %s: %s", response.Status, body))
    }

    file, err := os.Open(os.Args[1])
    if err != nil {
        panic(err)
    }
    defer file.Close()

    upload, err := http.NewRequest(http.MethodPut, presignedURL, file)
    if err != nil {
        panic(err)
    }
    upload.Header.Set("Content-Type", "image/jpeg")
    result, err := http.DefaultClient.Do(upload)
    if err != nil {
        panic(err)
    }
    defer result.Body.Close()
    if result.StatusCode < 200 || result.StatusCode >= 300 {
        body, _ := io.ReadAll(io.LimitReader(result.Body, 4096))
        panic(fmt.Sprintf("upload failed: %s: %s", result.Status, body))
    }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally a presign-and-upload probe, not a production thumbnail worker. Production code should validate the source before deriving an image, make retries idempotent with a receipt identifier, and treat standard queue delivery as at least once. The worker should also record the source and derivative keys before the retention clock starts.

What is the verification and rollback order?

Start with one canary bucket and one real browser origin. Check the preflight, then the upload, then a private read. Test a disallowed origin. Test a replacement key. The last test is the uncomfortable one, because it exposes whether the system has recovery semantics or merely a successful PUT.

My release gate is four checks:

  1. The application can mint a URL only for an authorized receipt key.
  2. The preflight permits the exact method and headers used by the browser.
  3. An unsuccessful upload cannot be recorded as a completed source.
  4. The retention job refuses to delete an original until the database shows a verified thumbnail and audit event.

If a gate fails, stop the thumbnail worker and retention job, preserve the original prefix, and correct the policy or database state. Do not claim recovery from an overwritten object when versioning or object lock is absent. For a property manager, that restraint is part of the audit trail.

References

Top comments (0)