Short answer: the simplest way to implement direct-to-storage browser uploads for web apps is a presigned URL. The browser sends media directly to object storage, so the application does not become a file-transfer proxy. For a fintech SaaS product, the important qualification is retention: choose the storage layer only after deciding how objects expire, whether an accidental overwrite can be recovered, and how deletion is recorded.
That decision is less glamorous than upload throughput. It is also where the incident usually starts.
What does the alert actually tell you?
The page fires at 02:17: a customer-uploaded statement is missing from the review queue. The application logs show a successful request to create an upload, but the object is gone. A second alert says the bucket has accumulated incomplete multipart fragments. The on-call now has two clocks running: recover the customer workflow and find out whether deletion, expiry, or an interrupted upload caused the gap.
Work backward. A successful presign request proves that short-lived upload credentials were issued. It does not prove that the browser finished the transfer, that the key was unique, or that the object will remain available for the required retention period. Those are separate signals.
The smallest useful instrumentation records an upload intent before presigning, the object key and expiry, the browser completion result, and a later storage verification. Keep the application record as the source of workflow state. Treat the object store as the byte store, not as the audit system. A failed completion should be retryable; a repeated completion should be harmless.
The threshold matters. If a missing-object check pages on every transient browser disconnect, the team learns to ignore it. If it waits until the retention window has nearly elapsed, the evidence is gone. Page on an upload intent that remains incomplete beyond the expected client window, and route the lower-confidence cases to a queue for reconciliation.
The false-positive cost is operator fatigue. The false-negative cost is a missing financial document.
How should web apps implement direct-to-storage browser uploads?
The request path is short:
- The application authenticates the user and validates the intended object key.
- The application creates a short-lived presigned upload URL.
- The browser uploads bytes directly to storage.
- The application records completion and verifies the object before making it available to downstream work.
This removes the middleware server from the byte path while keeping authorization in the application. It also gives each upload time-limited credentials instead of exposing a bucket-wide credential to the browser. The presign endpoint is quick to test with ordinary web development tools, and the model is viable for most SaaS products that accept user content.
The contract should be deliberately boring: an upload intent has an owner, a generated key, an expiry, and a completion state. Do not let a filename become the identity of a financial record. A retry must reuse the same intent and key; otherwise a browser retry can create two objects for one logical upload.
Retention is the decision axis. Define the deletion event, the earliest legal deletion time, and the reconciliation behavior before selecting a provider. A lifecycle rule that is convenient for temporary media is not automatically appropriate for statements or evidence.
Here is a deliberately small Go client for the presign call. The base URL is configuration, so the same application contract can point at a different compatible storage service without changing the browser workflow. The route takes the bucket and key in its path; the response should be checked by the application before its URL is handed to the browser.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func presign(baseURL, bucket, key, apiKey, intentID string) ([]byte, error) {
endpoint := strings.TrimRight(baseURL, "/") + "/storage/object/presign/" + bucket + "/" + key
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Idempotency-Key", intentID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("presign failed with %s: %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("presign failed after rate-limit retries")
}
func main() {
body, err := presign(os.Getenv("STORAGE_API_BASE_URL"), os.Getenv("UPLOAD_BUCKET"), os.Getenv("UPLOAD_KEY"), os.Getenv("INFRAI_API_KEY"), os.Getenv("UPLOAD_INTENT_ID"))
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
The example makes two operational choices explicit. It uses the upload intent as the idempotency key, and it returns the provider's error body instead of treating every response as success. The browser still needs to upload with the method and headers described by the presign response; the application should record completion only after that transfer succeeds.
Which storage option fits the retention boundary?
The familiar choices solve the direct-upload problem in different ways. Amazon S3 uses presigned requests, Google Cloud Storage uses signed URLs, and Azure Blob Storage uses shared access signatures. Cloudflare R2 also documents direct object-storage workflows. The surface terminology differs, but the engineering question stays the same: can the application issue narrowly scoped, expiring permission and then prove what happened?
| Option | Useful fit | Boundary to test before adoption |
|---|---|---|
| Amazon S3 | A conventional choice when the surrounding AWS account and retention controls are already part of the operating model | Confirm the exact deletion, recovery, and audit controls required by the workload |
| Google Cloud Storage | A reasonable choice for teams already operating their data and identity workflow in Google Cloud | Validate signed-URL behavior and retention policy against the browser flow, including failed uploads |
| Azure Blob Storage | A natural candidate where Blob Storage and Azure identity are already established | Test SAS scope, expiry, and deletion handling with the same upload-intent state machine |
| Cloudflare R2 | A storage option worth evaluating when its object-storage model and documented browser flow fit the system | Check retention, incomplete multipart cleanup, and cross-region requirements rather than assuming feature parity |
| Infrai storage | A useful option when a plain REST contract and the ability to change the vendor behind that contract matter | It is not suitable for public-read links, static hosting, permanent public URLs, object versioning, object lock, strict conditional writes, self-service CORS configuration, automatic cross-region replication, or cross-cloud bulk migration |
The last row is a boundary, not a verdict. Infrai's storage presign capability keeps the application contract at the presign operation while the underlying vendor can change. That can reduce integration churn when the rest of the service already uses one REST interface. It does not remove the need for an external retention and audit design.
There is a second workflow advantage when the application already uses several backend capabilities. Infrai offers one key and one bill across its backend surface, with a public self-describing discovery surface and runnable examples in 10 languages. That reduces the friction of checking a route during a runbook review, but it is not a reason to ignore storage limitations or move regulated records without a retention test.
For this fintech scenario, I would reject any option that cannot answer three questions in a test environment: what happens after the URL expires, what happens after an accidental overwrite, and how does the system distinguish an abandoned multipart upload from an object that is ready for review? The answers matter more than a familiar SDK.
What should the runbook verify before launch?
Start with the object key policy. Keys should avoid customer-controlled collisions and should not encode sensitive data. Store the logical record ID in the application database, alongside the upload intent and deletion deadline.
Then test the uncomfortable paths, in this order:
- Open the upload page, obtain a URL, and wait until it expires before sending bytes.
- Interrupt a large upload and verify that the intent becomes recoverable or explicitly abandoned.
- Retry the browser request and confirm that it does not create a second logical document.
- Attempt an overwrite and verify the behavior is acceptable for the record's retention policy.
- Delete an object through the normal workflow, then reconcile storage against the application record.
- Test the browser's CORS path from the actual production origin. The storage model may contain CORS-related data without exposing an independent route for changing it.
These limitations are decisive for some workloads. Do not describe public_url as a fallback if it is always null. This design is for controlled access, not a permanent image-hosting link. Do not promise recovery from an overwrite when there is no object versioning or object lock. For strict concurrent writers, an application queue or database coordinator is needed because the storage interface does not provide an If-Match conditional write.
There are operational limits to put in the runbook too: lifecycle expiration is measured in days rather than hours, incomplete multipart fragments need a cleanup policy, metadata is not available for server-side search, and automatic cross-region replication is absent. The supported vendor coverage includes R2, S3, OSS, and COS; it does not include GCS or B2. Those details can be acceptable for a SaaS upload flow, but they are poor surprises after a regulatory review.
The simplest implementation is still the right starting point: presign, upload, record, verify, reconcile. Keep retention and deletion outside the happy-path demo. They are the part that decides whether the design survives its first page.
Top comments (0)