European Receipt Storage: React/Next.js Private Browser Uploads, Signed URLs, and CORS
Short answer: for a logistics receipt system, use a backend-authorized direct browser upload with short-lived signed URLs, immutable object keys, and a storage policy that treats tenant isolation as the first constraint.
Short answer: for a logistics receipt system, use a backend-authorized direct browser upload with short-lived signed URLs, immutable object keys, and a storage policy that treats tenant isolation as the first constraint. React and Next.js can send the bytes directly to S3-compatible storage; the application server should decide who may create, read, replace, or delete a receipt. CORS is part of that boundary, not a substitute for authorization.
The easiest architecture is the one that leaves the fewest dangerous decisions in the browser. A browser upload should carry a file and a temporary capability. It should not carry a cloud master key, choose an unrestricted object path, or decide that a receipt belongs to tenant A because a form field said so.
That sounds strict because audit storage is strict. A receipt is evidence, not merely an image attached to a record.
No magic.
Receipt authorization starts with a failure test
Usually, yes, when the files are large enough that routing every byte through a Next.js server adds cost and failure surface. The server authenticates the user, resolves the tenant from the session, validates the proposed receipt metadata, creates an object key, and returns a short-lived signed upload URL. The browser then uploads directly to the object store. A later download uses the same shape: authorize on the server, mint a temporary read URL, and let the browser fetch the private object.
The URL is a capability with a narrow scope. Its useful properties are an operation, an object, and an expiry. The browser must not receive the storage account credential that can create arbitrary objects or enumerate another tenant's prefix. A signed URL also doesn't make the object public; it grants the holder temporary access to the operation it represents.
CORS answers a different question. It asks whether a browser origin may make the cross-origin request. It does not establish that the logged-in person owns the receipt. Configure the exact production origin and the preview-origin policy you actually intend to support, then test the upload from a real browser. A command-line request can prove that the endpoint accepts bytes; it cannot prove that the browser's origin checks are correct.
For private files, keep the database as the authority for ownership and lifecycle state. The object store holds bytes. The database says which tenant owns the receipt, which upload is accepted, which review state applies, and when a retention rule permits deletion.
How should React and Next.js handle signed URLs when a duplicate receipt becomes a reliability failure?
A private bucket is a useful baseline, not a complete isolation design. The critical question is where tenant identity enters the object key and where it is checked again.
Use a server-derived key with a tenant identifier and an opaque receipt identifier, for example:
from dataclasses import dataclass
from uuid import UUID
@dataclass(frozen=True)
class ReceiptUpload:
tenant_id: UUID
receipt_id: UUID
upload_id: UUID
extension: str
def object_key(upload: ReceiptUpload) -> str:
extension = upload.extension.lower().lstrip(".")
allowed = {"pdf", "jpg", "jpeg", "png"}
if extension not in allowed:
raise ValueError("unsupported receipt type")
return (
f"tenants/{upload.tenant_id}/receipts/"
f"{upload.receipt_id}/uploads/{upload.upload_id}/original.{extension}"
)
The tenant ID in this example is not permission by itself. It is a namespace that makes mistakes easier to detect and listing by prefix more useful. The authorization check must compare the authenticated principal with the tenant record before generating the key. On download, perform that comparison again before issuing a signed URL. Trusting a key supplied by the client is the classic path from a harmless upload form to an object-level authorization bug.
I treat a 409 during receipt finalization as a state conflict, not as permission to overwrite whichever object happens to be at the requested filename. The application can keep an upload in received, validated, or accepted state and make the transition idempotent. If two tabs submit the same receipt, a unique database constraint on the upload identity should arbitrate the race.
This is why immutable keys matter. Reusing tenant-17/receipts/May.pdf makes a replacement look like an ordinary successful write, while a unique upload ID preserves the original object until the application explicitly applies its retention policy. Object stores are good at storing objects; they are not a substitute for the business transaction that decides which object is authoritative.
How should a team choose direct browser upload with presigned storage access?
Compare responsibilities, not the number of lines in the first demo. A managed storage integration may feel easiest because it sits beside an existing database and authentication system. Direct S3-compatible storage may feel easiest because the team controls origin rules and signed-request details. A hosted edge store may simplify geographic delivery while introducing its own configuration model. None of those labels answers the tenant-isolation question for you.
| Path | What it makes convenient | Trade-off to verify |
|---|---|---|
| Application-managed storage integration | Keeping authentication, metadata, and file operations in one application boundary | Confirm that its authorization model maps cleanly to tenant ownership and private signed access |
| S3 presigned upload | Letting the backend mint a narrowly scoped upload while the browser transfers bytes directly | The team owns bucket policy, CORS, retention, recovery tests, and the operational burden around them |
| R2 direct browser upload | Sending browser traffic to an object store rather than through the application server | Validate private download behavior, allowed origins, and the regional or jurisdictional fit before rollout |
| Self-hosted S3-compatible service | Keeping deployment and data placement under the team's control | The team also owns durability, upgrades, monitoring, backups, and incident recovery |
The comparison changes when the application already has a strong data boundary. A Supabase-based team may reasonably value keeping storage policy near its existing auth and database policy. A team already operating S3 may prefer its established IAM, lifecycle, and audit process. R2 can be reasonable when its direct-upload and delivery model matches the product. Those are integration decisions, not universal rankings.
The catch is operational ownership. A path is not suitable when the frontend team needs to add preview origins but has no safe process for changing CORS, or when compliance requires a location and replication design the chosen service cannot provide. Stick with the option whose failure and recovery controls your team can actually operate. A shorter setup guide is not evidence of stronger isolation.
European governance for CORS and retention
Start the European review with the data, not with a region dropdown. Receipts can contain addresses, names, tax details, and images of paperwork. The organization needs to decide the applicable residency, processor, retention, deletion, and access-review requirements; I'm not sure which legal interpretation applies to a particular logistics business without that context. The technical design should record the chosen location and policy, but it cannot make that legal decision.
Geography also affects the recovery plan. Ask where the primary objects live, where backups live, how a tenant deletion propagates, and how the team proves that an expired receipt is no longer retrievable. Amazon's object lifecycle documentation is a useful reminder that retention automation is a storage policy, while Google Cloud's storage documentation shows the breadth of controls that must be evaluated in a cloud storage design. The exact feature set still needs verification for the selected service.
CORS deserves a concrete test matrix. Include the production origin, every permitted preview origin pattern, an unapproved origin, a signed upload, a signed download, and a failed preflight. Check the allowed methods and headers used by the actual browser client. Do not solve a CORS failure by making the bucket public; that trades a configuration problem for an access-control failure.
Lifecycle rules should match the business clock. A day-scale deletion rule is not the same thing as a ten-minute download URL, and neither one is a legal hold. Keep retention state in the application, apply storage lifecycle rules as a backstop, and define what happens to abandoned multipart uploads or partially completed transfers. The policy must be observable: an operator should be able to answer why an object still exists without searching browser logs.
Rollout: can the authorization path survive retries?
Roll out the authorization path before optimizing transfer speed. A small backend service can make the decisions explicit:
from datetime import datetime, timedelta, timezone
def authorize_receipt_upload(session, receipt, now=None):
now = now or datetime.now(timezone.utc)
if session.user_id is None:
raise PermissionError("authentication required")
if receipt.tenant_id not in session.tenant_ids:
raise PermissionError("tenant access denied")
if receipt.status not in {"awaiting_upload", "retryable"}:
raise ValueError("receipt is not accepting an upload")
expires_at = now + timedelta(minutes=10)
return {
"tenant_id": str(receipt.tenant_id),
"receipt_id": str(receipt.receipt_id),
"object_key": object_key(receipt.upload),
"expires_at": expires_at.isoformat(),
"operation": "upload",
}
The grant expires in 10 minutes.
The actual signer is deliberately behind this decision function. Whether the implementation calls an S3-compatible SDK, a REST endpoint, or an existing storage integration, it must not bypass the tenant check. Store the generated upload identity and expected object key before handing the URL to React. After the browser reports completion, verify the object metadata and mark the database row as received; only a later validation step should make it accepted for audit.
Consider a dispatcher working from a depot with an unstable connection. The browser obtains a URL, uploads most of a receipt, loses the connection, and retries while a second tab submits the same document. If both attempts write receipts/current.pdf, a successful HTTP response tells you almost nothing about which bytes the audit record now points to. With an upload ID in the key, a database uniqueness rule, and a finalization transition, the two objects remain distinguishable; one can be rejected as a duplicate without erasing the other. That extra identity is more valuable than a clever upload component because it gives operations a record to inspect after the browser has disappeared.
Test in stages. First, unit-test key construction and authorization with cross-tenant cases. Next, run browser tests against a non-production bucket using the same CORS shape as production. Then exercise expiry, retry, duplicate submission, deletion, and a user who belongs to one tenant but requests another tenant's receipt. Include a network interruption during a large upload. It is an ordinary failure, and ordinary failures deserve a designed response.
Watch the signals that expose isolation mistakes: signed-URL issuance by tenant, rejected cross-tenant requests, object keys outside the expected prefix, orphaned uploads, expired objects still referenced by the database, and CORS failures by origin. Avoid logging full signed URLs because query parameters can contain the capability itself. Log a request ID and a redacted object identity instead.
The final decision rule is compact: choose the transfer path that keeps bytes off the application server, but choose the authorization and recovery model that keeps tenant ownership in the application database. If a service cannot meet the required region, retention, CORS ownership, or recovery behavior, it is not the easiest choice for this workload, regardless of how quickly the first upload demo works.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.