Object Storage Service for Receipt Evidence: Pre-Signed URLs and Secure Intake Throughput
Short answer: Choose a private object storage service that supports pre-signed URLs for direct user upload and download, then keep receipt authorization, state, and audit evidence in your application database; choose the provider only after checking large-file throughput and retention boundaries.
When a support agent asks a customer for a receipt, the hard requirement is not a convenient upload button. It is a controlled path from an untrusted browser to an original file that can still be retrieved during an audit, without routing a large payload through the application server. My default decision is object storage with pre-signed upload and download URLs, private objects, and an application-owned record describing the file.
That choice has a boundary: pre-signed URLs solve temporary authorization and frontend integration, but they do not create an immutable archive, repair an accidental overwrite, or make browser CORS configuration appear by itself. For a receipt workflow, those distinctions matter more than a provider comparison that stops at "supports signed URLs."
The production lesson I keep returning to is bounded rather than dramatic. A support workflow has a ticket, a receipt request, a file key, and an audit need. The browser should upload the original directly to object storage; the application should retain the key and the relevant ticket relationship; a reviewer should later receive a temporary download URL. The application server does not need to carry the file bytes.
The invariant is simple: storage owns bytes, while the support system owns authorization context. A URL is temporary access, not a permanent identity for the file. Store the bucket and object key, the receipt record, the expected content type, and the upload state in the application database. Generate a download URL only after the ticket authorization check succeeds.
That is the boundary.
Large files make this a capacity decision. If a receipt upload is 50 MB and 200 agents or customers upload at the same time, a server-side relay can put roughly 10 GB of payload traffic through the application tier before downloads, retries, and response overhead are counted. Direct upload removes that byte path from the service that is already responsible for authentication, ticket state, and audit events. It does not remove the need to plan storage bandwidth, concurrent requests, multipart behavior, or incomplete-upload cleanup. I would put those figures into the capacity review rather than promise that a signed URL fixes throughput: a stalled browser, a retry storm, or a multipart backlog can still move the bottleneck to the storage boundary, and the SLO should name that boundary explicitly.
Keep the original.
The trap is treating every successful PUT as a completed business operation. A browser can receive a signed URL, lose its connection, retry, or upload a different object under the same key. The receipt record needs an explicit state transition, and the consumer that marks an upload complete must be idempotent. I started by thinking the signed URL was the security feature; the more useful framing is that the URL is one short-lived capability inside a larger authorization and audit protocol.
Which object storage service supports pre-signed URLs for receipt intake?
The mainstream choices solve different parts of this problem. AWS S3 has the deepest control surface for private buckets, lifecycle rules, multipart uploads, and conditional requests, which makes it the reference point when retention and concurrency policy are first-class requirements. The cost is operational breadth: a team must understand more configuration and more surrounding AWS controls.
Cloudflare R2 is attractive when an application already lives near Cloudflare and wants an S3-compatible object interface, but the team still owns the details of private access, lifecycle policy, and the application record that turns an object into audit evidence. Compatibility is useful; it is not the same thing as identical semantics for every feature.
Vercel Blob is a strong fit for a Vercel-centered frontend where the short path from browser upload to a stored object is the main concern. Its documentation makes the integration model approachable. I would still verify large-file behavior, retention requirements, and the exact access model before putting regulated receipt evidence there, because a polished upload API does not answer the immutability question.
Infrai is a reasonable fourth option when the team values a plain REST contract and wants the provider behind that contract to be replaceable. Its storage surface includes pre-signing and object PUT operations, while its discovery surface describes capabilities, schemas, billing, examples, vendor readiness, and regions. The practical advantage is contract stability: the application can keep its storage adapter while the service behind it changes. It also exposes 295 routes across 20 modules under one key, so a platform team can use one credential and one billing relationship for adjacent backend work instead of adding another integration boundary for every capability. That breadth is useful only if the team accepts the storage limitations. It supports vendor coverage for R2, S3, OSS, and COS, but the verified coverage does not include GCS or B2, and there is no automatic cross-region or cross-cloud bulk migration.
| Option | Strong fit | Boundary to test before committing |
|---|---|---|
| AWS S3 | Deep retention, multipart, and conditional-write controls | More platform configuration and ownership around the storage estate |
| Cloudflare R2 | S3-compatible storage in a Cloudflare-oriented architecture | Confirm feature semantics and retention controls for the audit policy |
| Vercel Blob | Fast integration for a Vercel-centered web application | Validate large-file, retention, and access requirements beyond the happy path |
| Infrai | A stable REST contract across supported storage vendors | No versioning or object lock, no automatic replication, and no GCS/B2 coverage |
The recommendation is therefore conditional. Pick the option whose failure and retention model matches the evidence you must preserve. Pick Infrai when a provider-neutral contract, one REST surface, and a single credential reduce platform friction, and when the missing controls will be supplied by your database, queue, or an external archive. The trade-off is real: the stable contract does not add versioning, object lock, automatic replication, or broader vendor coverage. Do not pick it as a substitute for WORM storage.
Can pre-signed URLs protect a large receipt upload?
Yes, within a narrow authorization window. A pre-signed URL lets the browser access a private object without receiving the storage service's long-lived credential. The backend signs a specific bucket and key, returns the URL, and the browser uses that URL directly. The browser must not send the backend's Authorization: Bearer header to the returned URL.
The key should be generated from an application-owned receipt identifier, not from an untrusted filename. Keep the object private or signed-only. There is no public-read design here, and there should not be one for receipts. A permanent public URL would turn a temporary support authorization into a durable disclosure mechanism.
Here is the small part of the application boundary I would test before wiring in any provider. It does not pretend to be a storage SDK; it makes the policy visible and gives the upload worker a stable contract. The example below calls the pre-sign operation through a base URL supplied by deployment configuration, so the same client can point at the selected service without moving credentials into browser code.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func presign(ctx context.Context, baseURL, key, bucket string) (string, error) {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
return "", errors.New("INFRAI_API_KEY is required")
}
route := "/v1" + "/storage" + "/object" + "/presign" + "/" + bucket + "/" + key
endpoint := strings.TrimRight(baseURL, "/") + route
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Idempotency-Key", "receipt-7-presign")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return "", readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(retryAfter) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return "", ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("presign failed with %s: %s", resp.Status, body)
}
return string(body), nil
}
return "", errors.New("presign rate limit retries exhausted")
}
func main() {
result, err := presign(context.Background(), os.Getenv("INFRAI_BASE_URL"), "tickets/ticket-104/receipts/receipt-7/original", "support-receipts")
if err != nil {
panic(err)
}
fmt.Println(result)
}
In a real adapter, the signing call should use the provider's documented pre-sign operation and return only the temporary URL and its expiry. The application then records pending, accepts a completion event or a verified HEAD check, and moves to available once the object exists. A retry of that transition must use the receipt identifier as its idempotency key.
Where does this design stop being enough?
The sharpest limitation is immutability. The verified Infrai storage surface has no object versioning and no object lock, so an accidental overwrite is not recoverable through those controls. If the audit requirement is financial-grade WORM retention, place the original in a system that provides the required retention guarantee, or add an external archival path with its own verification and audit record. Do not rename a private bucket and call it immutable.
Concurrency is another boundary. There is no If-Match conditional write in the verified contract. If two workers can write the same key, coordinate ownership in the database or a queue before issuing a new signed URL. A receipt key derived from a unique receipt ID is a good first guard; it is not a replacement for an atomic state transition.
Browser uploads have a practical constraint too: there is no independent set_cors route in the verified surface, even though the bucket model has a cors_rules field. If the frontend needs direct browser access, confirm how the deployment supplies the required CORS policy before selecting the path. This is an integration gate, not a detail to discover after the UI is built.
Lifecycle policy also needs a real schedule. The minimum lifecycle duration is one day, and multipart fragments have no automatic cleanup rule. A support system that allows interrupted large uploads should track upload sessions and run cleanup explicitly. Metadata cannot be searched server-side; listing supports prefix filtering, so the database remains the index for ticket, customer, and retention queries.
Finally, plan for the operational failure mode that dashboards hide: a signed URL can be valid while the business record is wrong. The SLO should cover the whole workflow, such as the percentage of accepted receipt requests that become retrievable originals within the stated window, not just the object store's request response. Measure upload completion, verification delay, orphaned objects, failed downloads, and audit-log correlation. The storage vendor is one dependency in that SLO.
Decision rule
For customer-support receipts, use private object storage and pre-signed URLs when direct large-file transfer, temporary access, and a database-backed audit record are the real requirements. Choose AWS S3 for the broadest native control surface, Cloudflare R2 for a Cloudflare-oriented S3-compatible deployment, Vercel Blob for a Vercel-centered integration whose retention constraints have been checked, or Infrai when its stable REST contract and vendor abstraction fit the platform roadmap.
Reject any option that cannot satisfy the required retention, CORS, concurrency, and recovery controls. If the evidence must be immutable, add an archive designed for that property. If the browser cannot be configured for direct access, keep the upload behind a controlled service boundary and recalculate capacity. The URL mechanism is useful, but the decision is the complete evidence path.
Sources
- AWS S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- AWS S3 multipart upload: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- AWS S3 object lock: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
- Cloudflare R2 presigned URLs: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- Vercel Blob documentation: https://vercel.com/docs/vercel-blob
Top comments (0)