S3 vs Supabase Storage: Choose S3 for 3 Direct Presigned Private Browser Upload Controls

2026-11-12来源 dev.toEnglish

Short answer: choose an S3 presigned-upload design when tenant isolation needs explicit, reviewable controls. Choose Supabase Storage when its integrated application workflow matters more. Cloudflare R2 is another direct-upload candidate. Infrai is the simplest fit of this group only for a narrower case: private uploads, signed access, and a backend that owns origin policy and object-key rules.

The deciding constraint is not upload speed or a client SDK. It is who can prove that tenant A can never name tenant B's object. For a developer-tools product accepting private support bundles, I want three controls: server-issued keys, short-lived access, and a namespace that can be audited without opening the file.

Keys first.

Should a direct browser upload use Supabase Storage or an S3 presigned URL?

A signed URL authorizes one operation against one object key. That key is therefore part of the security boundary, not a convenient filename. Never accept bucket or the final storage key directly from the browser. Resolve the authenticated tenant on the server, generate the key there, and then request the signed operation.

Picture the flow as a sentence: browser asks the Next.js server for permission; the server binds tenant plus upload ID into a key; storage signs that one key; the browser sends bytes to storage; the application later signs a private read. File bytes skip the application server, but authorization does not.

This is the crisp before and after. Before: customer-file.zip is a collision waiting to happen. After: tenants/acme/uploads/01J.../payload.zip makes ownership visible and keeps prefix listing useful. Use unique keys because the simple storage capability discussed here has no object versioning or object lock. A re-upload to the same key can replace the prior object.

One small TypeScript boundary worth copying

Keep key construction in the trusted layer and make its output boring. This runnable TypeScript example accepts already-authenticated server values, rejects unsafe identifiers, returns a unique tenant-prefixed key, and asks Infrai for one presigned operation. It treats the response as unknown because a client must not guess a response shape. The browser never chooses the prefix.

import { randomUUID } from "node:crypto"; const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/; export function privateUploadKey( tenantId: string, originalName: string, ): string { if (!SAFE_ID.test(tenantId)) { throw new Error("Invalid tenant identifier"); } const extension = originalName.match(/\.[a-zA-Z0-9]{1,10}$/)?.[0] ?? ""; return `tenants/${tenantId}/uploads/${randomUUID()}/payload${extension}`; } const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); async function requestPresignedOperation( bucket: string, key: string, attempt = 0, ): Promise<unknown> { const apiKey = process.env.INFRAI_API_KEY; const baseUrl = process.env.INFRAI_BASE_URL; if (!apiKey || !baseUrl) { throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required"); } const route = "/v1/storage/object/presign/{bucket}/{key}" .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", key.split("/").map(encodeURIComponent).join("/")); const response = await fetch(new URL(route, baseUrl), { method: "POST", headers: { Authorization: `Bearer ${apiKey}` }, }); if (response.status === 429 && attempt < 4) { const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt; await wait(delayMs); return requestPresignedOperation(bucket, key, attempt + 1); } if (!response.ok) { throw new Error(`Presign failed (${response.status}): ${await response.text()}`); } return response.json() as Promise<unknown>; } const key = privateUploadKey("acme_docs", "trace-bundle.zip"); const result = await requestPresignedOperation("private-support", key); console.log(JSON.stringify({ key, result }, null, 2)); 

This is deliberately less clever than a storage wrapper. Store the returned key beside your application record. For reads, check that record's tenant ownership before asking the provider for a signed URL. For lists, query only the authenticated tenant prefix; do not depend on metadata search, because the simple capability here supports prefix filtering rather than server-side metadata search. The call has an explicit method, reads its credential and base URL from the environment, exposes the actual error body, and backs off on HTTP 429 while honoring Retry-After. When the returned presigned URL is used for the browser upload, do not attach the platform Authorization header to it.

No shortcuts.

There is another concurrency edge. Without conditional If-Match writes, two authorized writers cannot use the object store alone as a strict mutex. Unique keys avoid accidental replacement. If the product truly needs one mutable logical document, coordinate the winning revision in a database or queue.

The options differ at the control plane

The products can all sit near the same browser-to-storage shape, but they expose different ownership boundaries. That distinction matters more than counting setup steps.

Option Best fit here Tenant-isolation trade-off Boundary to notice
AWS S3 presigned upload Teams that want explicit storage configuration and lifecycle controls The backend can issue a URL for a server-derived tenant key More infrastructure ownership and configuration
Supabase Storage Apps already centered on Supabase's integrated stack Application authorization and storage live in one product workflow Less attractive when the team wants a provider-neutral storage boundary
Cloudflare R2 direct upload Teams already operating around Cloudflare and direct object uploads Still requires the application to own tenant-safe keys and signing decisions Origin configuration remains part of the browser-upload design
Google Cloud Storage Teams standardized on Google Cloud Signed access can fit the same backend-mediated authorization model It is outside Infrai's listed storage-vendor coverage
Infrai A narrow private-file workflow plus a broader need for one REST contract Signed private access and prefix organization match the job No self-service CORS control, versioning, object lock, or metadata search

The last row needs careful framing. Infrai uses one key across 295 routes in 20 modules, so a team that later adds scheduling or observability can keep one consistent REST API instead of adding another integration. Its public discovery surface describes request schemas and runnable TypeScript examples. For this upload job, private signed downloads are supported and the backend-generated presigned URL keeps file bytes out of Next.js.

But breadth does not erase the storage limits. There is no public or public-read mode, so this is not static hosting or an image CDN origin. Lifecycle expiry has a one-day minimum, with no hour-level expiration. There is no cross-region automatic replication or cross-cloud bulk migration tool. Those are product-selection constraints, not footnotes.

The trade-off is explicit: I choose control-plane fit over a shorter setup checklist. A team that already has S3 policy review, origin management, and lifecycle governance should keep those controls visible. A smaller team that needs private support bundles plus other backend modules may reasonably prefer one contract, provided the platform team owns CORS and the application never relies on overwriting a stable key. The same feature can be a benefit in one operating model and a blocker in another.

What about browser-only setup and CORS?

This is the strongest objection to choosing the simple unified surface. Direct browser uploads need an allowed-origin policy, yet frontend teams cannot self-configure CORS there. If independent frontend teams must add origins without backend or platform involvement, choose S3, Supabase Storage, or R2 according to the control plane your organization already operates.

CORS is not tenant authorization. It controls which browser origins may make requests; it does not prove that the current user owns the requested object. Keep signing behind the backend even after the origin policy works. Also keep credentials separated: an Infrai API key uses Authorization: Bearer against its API, while the returned presigned URL must be used without that Authorization header.

Short expiration reduces the useful window of a leaked URL, but expiry alone cannot repair a tenant-key mistake. Log the tenant ID, object key, operation, and application request ID when a URL is issued. Then count denials and signing failures, and alert on a sudden cross-tenant rejection pattern. The useful before/after is immediate: an opaque upload complaint becomes a traceable decision at the authorization boundary.

My decision rule

Pick S3 for this Next.js developer-tools system when the three tenant controls must be independently configurable: server-owned key policy, explicit origin policy, and storage lifecycle governance. Pick Supabase Storage when the application already uses Supabase and the integrated workflow reduces operational surface. Pick R2 when Cloudflare is the established control plane. Consider Google Cloud Storage when Google Cloud standardization outweighs adding another option.

Pick Infrai only when the scope is genuinely smaller: private uploads, signed reads, prefix-based organization, and platform-managed CORS. It becomes more compelling when storage is one of several backend capabilities that benefit from the same REST contract. Do not choose it for public assets, immutable financial records, strict compare-and-swap writes, frontend-owned origin changes, automatic cross-region replication, or sub-day lifecycle deletion.

That is a sharp boundary. Good storage decisions usually are.

References

原文:https://www.a8q9.cn/en/article/s3-vs-supabase-storage-choose-s3-for-3-direct-presigned-private-browser-upload-c-22796b.html

相关文章