DEV Community

MaximilianNilsson7568
MaximilianNilsson7568

Posted on

Private File Storage in Next.js: A Server Action for Browser Direct Uploads

Short answer: for a B2B SaaS product accepting private user uploads, let a Next.js server action authorize a short-lived upload session, let the browser send bytes directly to private object storage, and issue an expiring download URL only after a separate read authorization check.

The deciding constraint is not upload speed. It is the boundary between access control and delivery simplicity. The application must decide who may create, replace, download, and delete a file; storage should carry the bytes without becoming the application's user database.

What must the server action prove before a private upload exists?

The server action should accept only file metadata and an upload ID, derive the user from the authenticated session, validate size and content type against policy, create the database row, and request a presigned write. The browser then performs the direct upload with that URL. Afterward, a second server action checks the object and marks the row ready. I've found this split easier to reason about than a single “upload succeeded” flag because it leaves the storage transfer and the business transition visibly separate.

The critical path is easier to audit when naming is deterministic and credentials never cross the browser boundary:

import re
from uuid import UUID


def private_object_key(user_id: str, upload_id: str, filename: str) -> str:
    """Return an application-owned key for one private upload."""
    UUID(upload_id)
    label = re.sub(r"[^A-Za-z0-9._-]", "", filename)[-80:] or "file"
    return f"users/{user_id}/uploads/{upload_id}-{label}"


def upload_session(user_id: str, upload_id: str, filename: str) -> dict:
    key = private_object_key(user_id, upload_id, filename)
    return {
        "key": key,
        "state": "uploading",
        "write_url": presign_write(key, expires_in_seconds=900),
    }
Enter fullscreen mode Exit fullscreen mode

The presign request is an implementation detail of the storage control plane; the browser only needs the returned URL and the exact method and headers required by that signature. A client must not silently change Content-Type, checksum headers, or the HTTP method after signing. That is a common source of an HTTP 403 that looks like an authorization bug but is really a signature mismatch. A 900-second write window is an application policy in this example, not a promise about every storage service.

Once the transfer completes, the application should inspect the object, compare the result with its upload policy, and perform an idempotent uploading to ready transition. A retry must use the same upload ID. If a second request creates a different key, the user gets duplicate records and the cleanup job gets an ambiguous object. Don't hide that distinction behind a generic success response.

For downloads, authorize the database row first and create a short-lived read capability second. Set a safe response disposition and a server-selected filename. The Content-Disposition header is a delivery concern; it does not make a private object public, and it does not replace the authorization check described above.

Which audit records survive an interrupted browser upload?

The first failure is treating a signed URL as a permission system. Anyone who obtains an unexpired URL can generally use the capability within its scope, so keep its lifetime narrow and do not put it in logs, analytics events, or durable client state. A URL for one key must not be reused as a general upload endpoint.

Make it boring.

The second is trusting the browser's metadata. image/png is a useful hint for the interface, not proof of file content. If the product requires malware scanning, archive inspection, or content verification, the object must remain quarantined until that asynchronous check completes. The user-facing row should distinguish uploading, quarantined, ready, and rejected; collapsing those states produces accidental downloads. I use a 403 test as a boundary check: an expired write capability should be rejected, while a valid read capability for an authorized tenant should still work. The exact response body varies by storage implementation, so the test should assert the access result and the application state rather than a vendor-specific error string.

The third is assuming a successful browser request means the business operation succeeded. The network can fail after storage accepts the bytes and before the browser reaches the application. A reconciliation worker should compare pending rows with the expected object key, while a retention policy removes abandoned objects. Lifecycle rules can help with coarse cleanup, but an application promise such as “disappears in fifteen minutes” needs an application-owned deletion path and timestamp.

Then there is CORS. The bucket must allow the precise application origin, method, and headers used by the browser. Test the production origin, an expired URL, a wrong content type, a second completion request, and a user attempting to finalize another user's upload. Do not call a CORS failure a storage outage until the browser network panel and the storage response have been inspected separately.

When is direct browser delivery the wrong boundary?

The comparison should be about control and failure recovery, not a feature-count contest.

Boundary Choose it when The catch
Browser to private object storage Files are large and the application need not inspect every byte before receipt CORS, signature expiry, and reconciliation become operating responsibilities
Application proxy Every byte must be scanned, transformed, or rejected before storage Application bandwidth, timeouts, and worker capacity become part of the upload path
Browser to a managed upload service The team accepts an additional service boundary for resumability or workflow tooling Data lifecycle, identity mapping, and export controls need a second review

The direct boundary is appropriate when the application can quarantine and verify asynchronously. A proxy is appropriate when policy requires synchronous inspection or when files are small enough that operational simplicity outweighs the extra hop. A managed service is appropriate when its resumable workflow is a real requirement and the team has documented how private access maps back to the application's user and tenant. The trade-off is explicit: direct delivery reduces application byte handling but adds CORS, expiry, and reconciliation work. Your mileage may vary if the team has no owner for those operations.

None of these options removes the need for tenant authorization. A key prefix is useful organization, but it is not a policy decision by itself. Every read and every state-changing operation still needs an owner or tenant check in application code.

Decision record: tenant governance stays outside the bucket

The design has four invariants. The bucket remains private. The server, never the browser, owns the object key. The database records the user, object key, state, and expiry. A download URL is a temporary capability, not a permanent identity for the file.

That gives the workflow a clear failure boundary. A user may successfully upload bytes while the database row is still uploading; a failed finalization must not turn that object into a visible attachment. Conversely, a missing object must not be hidden by a row that says ready. The application verifies the object before the state transition.

I keep the original filename as display data, not as an authorization input. A filename can contain path separators, confusing Unicode, or a second extension. The generated key should contain an authenticated user identifier and an upload identifier, while the filename is reduced to a bounded label for humans. It is a small rule with a large blast radius.

The database is the file index. Bucket listings and object metadata are useful for reconciliation, but they are a poor substitute for ownership, retention, and workflow state. An abandoned upload can be found by querying rows whose session expired; no prefix scan is required. This is the part teams tend to postpone: a request that has already minted a URL still needs an owner, an expiry, a cleanup path, and a state transition, and those details must agree when the browser disappears halfway through a multi-megabyte transfer.

Why permanent URLs and proxy-everything fail this test

I reject permanent public URLs for private user uploads because revoking a database row cannot revoke a URL that has already escaped. A signed read URL makes the exposure window explicit, although it still requires careful handling of logs and referrers.

I also reject proxying every file by default. It makes delivery look centralized, but the application then carries byte transfer, connection lifetime, and retry behavior that object storage already handles. The proxy is valid when inspection or transformation is the requirement; it is unnecessary when the application only needs to authorize a transfer. This direct-upload model is not suitable when policy requires synchronous inspection before any durable write; use an application-controlled intake path in that case. It is also not a fit for a product that needs permanent unauthenticated links, so keep a public-content delivery design separate from this private-file workflow.

There is no universal winner here. The right design is the one whose failure modes the team can observe, reconcile, and explain to a tenant after an interrupted upload.

References

Top comments (0)