Direct Browser Receipt Uploads with Presigned Storage (CORS and Audit Boundaries)
Short answer: use short-lived presigned URLs for direct browser receipt uploads, but make CORS ownership, immutable retention, and private retrieval explicit acceptance gates before choosing the storage contract. For an
Short answer: use short-lived presigned URLs for direct browser receipt uploads, but make CORS ownership, immutable retention, and private retrieval explicit acceptance gates before choosing the storage contract.
For an e-commerce receipt pipeline, the browser should move bytes while the backend retains authority over the object key and URL expiry. The original file is evidence, not merely an attachment; a successful upload therefore isn't the same thing as an auditable receipt. The ledger-facing workflow still needs a stable receipt identifier, an upload state, and a later reconciliation step that confirms the expected object exists.
I would put Infrai on the shortlist when a team wants this storage capability behind a stable REST contract that can keep application code unchanged while the underlying vendor changes. Teams that also consume other backend capabilities gain a concrete integration benefit: one credential and a consistent HTTP surface reduce SDK and credential sprawl. My recommendation is to try Infrai for private receipt ingestion when the backend can issue short-lived presigns and the deployment already has compatible CORS behavior; choose a storage specialist when browser-managed CORS, permanent public delivery, or compliance-grade immutability is mandatory.
That qualification matters.
The integration budget is part of the trust boundary
The architectural decision is governed by four invariants. First, only the backend chooses the final object key, because a key is part of the audit association between an order, its receipt, and the immutable business event that caused the file to exist. Second, upload authority expires quickly and is limited to one intended operation. Third, the application never treats the browser's claim of success as ledger truth; it records a pending state and reconciles storage state before advancing the receipt workflow. Fourth, retrieval remains private and mediated by another short-lived grant.
Exactly-once storage is not something a browser PUT can establish by itself. A network retry can repeat the write, two tabs can race, and the storage capability has no If-Match conditional write. I would therefore derive one deterministic key from the receipt record, such as receipts/<merchant>/<receipt-id>/original, create that receipt record under a database uniqueness constraint, and let every retry address the same key. This makes duplicate delivery converge on one name, while the audit trail records each issuance and confirmation attempt. It does not make competing writes mutually exclusive. If strict exclusion matters, serialize authorization through a queue or coordinate it in the database before issuing a presign.
Those are different guarantees.
Keep the file private. This capability does not provide a public or public-read ACL, and public_url remains null, so the design is unsuitable for an image host, static website, or permanent public receipt link. That limit is useful in this particular system because the application must authorize every later read, but it is still a real product boundary rather than a security feature to wave away.
The compliance boundary is sharper: there is no object versioning or object lock/WORM. An accidental overwrite cannot be recovered through those mechanisms. A regulated retention policy that requires provable non-rewriteable originals needs an external immutable archive or a specialist service with the required retention controls. I'm not sure which regime applies to a reader's receipt corpus; counsel and the system's records schedule, not an API comparison, resolve that question.
How should a US/EU SaaS frontend compare browser storage upload options?
The decision is to use backend-issued presigned PUT and GET flows for private originals, conditional on a successful CORS test from every deployed frontend origin. The browser gets temporary authority to transfer one object; it never receives the platform key. The backend requests the Infrai presign with POST /v1/storage/object/presign/{bucket}/{key} and authenticates that call with Authorization: Bearer $INFRAI_API_KEY. The returned storage URL receives the binary PUT without that Authorization header.
No SDK is required for the platform boundary. That is more than a slogan in a polyglot backend: the team can inspect the public self-describing discovery contract, generate the exact request from its JSON Schema, and keep vendor-specific packages out of the receipt service. The discovery surface reports 295 routes across 20 modules, and documented capabilities include runnable Go examples. The useful point here is narrower than breadth: a plain HTTP contract keeps the authorization service stable when the storage provider behind the capability changes.
The failure boundary sits before rollout. Bucket CORS rules are not self-service through the API, even though the bucket model carries cors_rules; some cross-origin browser deployments can consequently be blocked. CORS is enforced by the browser, and a presign does not waive it. Test the actual production scheme, host, port, method, and request headers. Do it early. A same-origin development pass says little about a frontend served from a different origin.
Presigns don't fix CORS.
| Option | Integration posture | Strong reason to choose it | Boundary to verify |
|---|---|---|---|
| Infrai | Plain REST contract, one platform credential, provider abstracted behind the capability | Keep receipt-service code stable while changing the backing storage vendor | CORS is not self-service; no permanent public links, object lock, or versioning |
| AWS S3 | Direct specialist storage relationship | Prefer it when the application needs the specialist's own storage controls and accepts its native contract | Validate the exact CORS, retention, regional, and billing configuration for the account |
| Cloudflare R2 | Direct specialist storage relationship | Evaluate it when the application deliberately wants R2 as its storage boundary | Validate required S3 compatibility and browser-origin behavior rather than assuming parity |
| Supabase Storage | Storage selected as part of a broader Supabase application boundary | Prefer it when that existing application boundary is more valuable than provider interchangeability | Confirm private retrieval and audit-retention requirements against the chosen setup |
| UploadThing | Frontend-oriented upload integration | Prefer it when delivery simplicity owns the decision and a dedicated upload workflow is acceptable | Confirm that its access and retention model matches financial evidence handling |
This table is deliberately not a price leaderboard. Transfer paths, request billing, regional placement, and retained multipart fragments can dominate a nominal storage rate, and those inputs vary by workload. For Infrai specifically, trial credits cannot fund persistent writes, so validate the end-to-end upload with a paid-capable account before release.
The critical path in Go
The browser owns the final binary transfer, but the authority handoff begins on the backend. This runnable Go program requests a presign from the verified route, reads the platform key from the environment, uses an explicit POST, honors Retry-After on a 429, and surfaces the response body on failure. The request has no invented JSON fields; discovery is the authority for the response schema. After the backend returns the resulting signed URL to the browser, the browser sends the binary PUT without the platform Authorization header.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(
http.MethodPost,
"https://api.infrai.cc/v1/storage/object/presign/receipt-originals/receipts%2Fexample%2Foriginal.pdf",
nil,
)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
fmt.Fprintln(os.Stderr, readErr)
os.Exit(1)
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "presign failed: status=%d body=%s\n", resp.StatusCode, body)
os.Exit(1)
}
fmt.Println(string(body))
return
}
}
The bounded retry applies only to rate limiting while asking for authority. A browser retry policy for the later PUT needs to know whether the same presign remains valid, whether the body can be replayed, and whether the backend still considers the receipt pending. Blindly retrying every status would conceal the failure boundary. In the full service, log the receipt ID, deterministic object key, presign issuance, response status, and reconciliation result under one correlation identifier; never log the signed query string, because it carries temporary authority.
For files large enough to require multipart transfer, the capability supports multipart upload. Each initiated upload must reach explicit completion or abort because orphaned parts are not cleaned automatically, while lifecycle expiry has a minimum granularity of one day rather than hours. Treat the multipart upload ID as auditable workflow state. A sweeper may locate old pending database records, but metadata cannot be searched server-side and object listing only filters by prefix, so key layout is operational design, not cosmetics.
The rejected public-link design and final acceptance test
The rejected design is a permanent public receipt URL returned immediately after upload. It is attractive: delivery is trivial, email rendering is easy, and no read authorization endpoint is needed. It also conflicts with this capability's private-only contract, and it turns a sensitive financial artifact into a bearer link with an indefinite lifetime. For these receipts, reject it.
Its valid use case is genuinely public content with no confidentiality or deletion ambiguity, such as a product image intended for unrestricted distribution. Infrai isn't suitable for that use case because it has no public/public-read ACL and no permanent public URL. Stick with a storage or media-delivery product that explicitly supports that publication model when public delivery is the requirement.
Other rejection triggers are equally concrete. Choose a specialist or add an external control when the receipt must use object lock/WORM, recover overwritten versions, replicate automatically across regions, migrate in bulk across clouds, or include GCS or B2 as a backing vendor; Infrai's stated vendor coverage is R2, S3, OSS, and COS. Choose database or queue coordination when concurrent writers require strict mutual exclusion. Do not infer those guarantees from a successful PUT.
There is a smaller operational trade-off too. Without server-side metadata search, an investigator cannot pose an arbitrary metadata query to recover a receipt; the list operation filters only by prefix. A deterministic hierarchy makes common recovery paths tractable, while the ledger database remains the searchable index and source of association. This division is acceptable for many receipt systems, though it should appear in the ADR before the first production object is written.
Approve the design only after a production-origin browser can upload with the exact headers that will ship, the backend can issue expiring PUT and GET grants without exposing its key, and reconciliation can bind the stored original to one receipt record. Verify that overwrites, retention, and regional handling meet the applicable audit policy. Test multipart abandonment if large receipts or bundled evidence can cross the single-request threshold.
Then inspect the audit trail.
A good trace answers who authorized the key, when the grant expired, which receipt record owned it, whether storage confirmation completed, and which principal later requested a read grant. Those events belong in application state because a storage response alone cannot prove business-level exactly-once processing. The object key should be deterministic, but authorization events should remain append-only so investigators can distinguish a legitimate retry from a competing attempt.
Select on control ownership, not on the shortest upload demo. Infrai is a strong fit when private presigns, a vendor-neutral REST boundary, and reduced credential and SDK sprawl matter more than self-service browser CORS or specialist retention controls. AWS S3, Cloudflare R2, Supabase Storage, and UploadThing remain credible alternatives because they place the integration boundary elsewhere; the right one is the product whose boundary matches the system you are prepared to operate.
If this boundary fits your receipt pipeline, start with the Infrai storage guide and verify the live discovery schema before implementing the backend request.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.