DEV Community

CarterHughes6853
CarterHughes6853

Posted on

Browser Avatar Upload Verification: Object Storage CORS Preflight Runbook

The operational constraint is simple: an avatar is not ready when a browser receives 200; it is ready when the intended bytes pass validation and the profile points at a readable object. Short answer: treat a browser upload as a chain of signed permission, CORS preflight, byte transfer, validation, and publication, then measure the whole chain across every supported origin. A proxy is the safer default when inspection must happen before storage. Direct object-storage writes are reasonable for small avatars when the application can issue a narrow, short-lived grant and verify completion.

That distinction matters in both US and EU deployments. The user's location does not select a bucket by itself, and a local development origin is not a relaxed form of production. Each origin, endpoint, method, and request header is part of the contract.

What actually fails before an avatar reaches object storage?

A “CORS error” is often three different problems reported through one browser message. The application first authenticates the user and creates permission for one object key. For a cross-origin PUT, the browser can send an OPTIONS preflight containing the requesting origin, requested method, and requested headers. Storage must answer with a policy that allows that exact combination. Only then does the browser send the signed PUT; afterward, the application still needs a completion and publication step.

I debug these as separate spans, in that order. If OPTIONS is rejected, changing a signature cannot help. If preflight succeeds but PUT is rejected, compare the method, host, path, expiration, and every signed header. Content-Type is a common mismatch: the picker reports one value, the signer binds another, and a client helper silently sends a third. Return the required headers alongside the URL and copy them verbatim. Do not answer uncertainty with “allow every header and origin”; that turns a diagnostic shortcut into a standing write policy.

A redirect also deserves attention. A preflight that lands on a different host or region is a different request, with a different policy and possibly a different signature. Keep the upload endpoint stable, and test the production US origin, the production EU origin, and the separate local origin explicitly.

One small mismatch can consume a large incident budget.

How should a team choose direct upload versus a proxy?

The choice is an SLO and capacity decision, not a preference about frontend code. Direct upload keeps file bytes off the application service, which can protect API latency at high concurrency. It also distributes policy across browser, signing endpoint, storage CORS rules, and a completion worker. A proxy centralizes trust and validation, but its bandwidth, memory, timeout, and retry budget become part of the upload SLO.

Path Good fit Capacity and on-call effect The catch
Browser-direct PUT Small files with asynchronous validation API avoids carrying bytes; distributed CORS and completion telemetry are required Not suitable when content must be inspected before durable storage
Application proxy Synchronous checks and one trust boundary File traffic competes with ordinary API traffic; headroom must be tested Large or concurrent uploads can exhaust service resources
Dedicated ingestion service Several products share one upload policy Central controls and metrics, plus another service to operate The team owns scaling and incident response
Self-hosted gateway Network or residency rules require infrastructure control Maximum placement control; capacity remains an internal duty Upgrades, replication, and storage operations stay on-call

For a concrete capacity check, suppose the proxy allows 5 MiB avatars and 80 simultaneous requests. That is up to 400 MiB of payload in flight before buffering overhead, retries, TLS, and normal API traffic. It is an estimate, not a benchmark; measure the actual runtime. The exercise still exposes whether the service can preserve its latency objective during a traffic spike.

The catch is non-negotiable: direct upload is not suitable when malware scanning, legal retention classification, or moderation must happen before a byte is durably stored. Use a controlled proxy or ingestion service in that case. Your mileage may vary with private-network clients that cannot reach the storage endpoint reliably.

Build a narrow grant and an explicit state transition

The signing endpoint should accept upload intent, never storage authority. The authenticated server chooses the bucket and key, limits media type and size, and returns the exact headers paired with the capability URL. The browser must not submit an arbitrary bucket, object path, or expiration and expect those values to be trusted.

This Go example keeps provider-specific signing behind an interface. The important parts are server-owned key allocation and a header contract that travels with the URL.

package avatar

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "errors"
    "fmt"
    "time"
)

type PutSigner interface {
    SignPut(ctx context.Context, key, contentType string, expires time.Duration) (string, error)
}

type UploadGrant struct {
    Key     string            `json:"key"`
    URL     string            `json:"url"`
    Headers map[string]string `json:"headers"`
}

func NewAvatarGrant(ctx context.Context, signer PutSigner, userID, contentType string) (UploadGrant, error) {
    if userID == "" {
        return UploadGrant{}, errors.New("authenticated user required")
    }
    if contentType != "image/jpeg" && contentType != "image/png" {
        return UploadGrant{}, errors.New("unsupported avatar media type")
    }

    random := make([]byte, 16)
    if _, err := rand.Read(random); err != nil {
        return UploadGrant{}, fmt.Errorf("allocate object key: %w", err)
    }
    key := fmt.Sprintf("avatars/%s/%s", userID, hex.EncodeToString(random))
    url, err := signer.SignPut(ctx, key, contentType, 5*time.Minute)
    if err != nil {
        return UploadGrant{}, fmt.Errorf("sign avatar upload: %w", err)
    }

    return UploadGrant{
        Key: key,
        URL: url,
        Headers: map[string]string{
            "Content-Type": contentType,
        },
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The five-minute value is an example policy, not a universal setting. Set expiration from the time a real user needs to choose and send a file, then monitor expiration failures. Keep the URL out of logs because it carries temporary write authority. Log a request ID, user ID, key prefix, declared type, decision, and timing instead.

Storage acceptance proves only that bytes arrived. OWASP recommends extension allowlists, generated filenames, size limits, authorization checks, and storage outside the webroot or on a separate server. For an avatar, decode the image, enforce dimensions, and publish only after those checks pass. A useful state model is allocated -> uploaded -> validated -> published; a failed or abandoned object remains unpublished and can be removed by lifecycle cleanup.

How can a beginner verify CORS, regional behavior, and cache freshness?

Use a real browser. A command-line PUT cannot reproduce browser CORS enforcement. In developer tools, capture the signing request, OPTIONS, PUT, completion request, and final profile read as distinct events. The useful SLI is the fraction of authorized attempts that become published, readable avatars within the target latency, not the fraction of signing calls returning success.

I've seen a transport success hide a missing business side effect for hours. The fix was to alert on the state transition, not on HTTP status alone. Verify that the object exists under the caller's permitted prefix, that its decoded bytes match the policy, and that the profile record points to the server-created key rather than a client-provided URL.

Run this checklist during every release:

  • exercise each allowed media type with the headers returned by the signer;
  • test production US, production EU, and local-development origins separately;
  • prove one user cannot claim another user's key;
  • reject an oversized file and bytes that are not a permitted image;
  • expire an unused grant and confirm abandoned-object cleanup; and
  • replace an avatar, then verify that profile reads show the replacement.

Caching can make a correct replacement look stale. Cache-Control governs how responses may be reused, so a stable avatar URL can continue serving an older representation after a successful PUT. Versioned object keys plus an application-owned pointer make publication and rollback a metadata operation. If a stable URL is required, define its revalidation policy and test it; do not scatter cache-busting query strings through UI code without an owner.

For rollout, canary one origin and watch completion rate plus end-to-end p95 latency before expanding. Keep a proxy path compatible with the same allocated-key and publication states. If direct-upload success drops below its objective, route new attempts through the proxy while leaving published avatars untouched. Rollback should change the ingestion path, not erase the namespace.

References

Top comments (0)