When a developer tool exports a private PDF, tenant isolation changes the design: the API route must authorize the snapshot before it creates a short-lived signed download URL, and the object store must never become the authorization layer. Short answer: keep the Next.js API route as the policy boundary, issue a signed URL only after a tenant-scoped lookup, and make the PDF object private by default.

That sounds tidy until a restore request arrives during an incident. A user can have a valid session and still be asking for another tenant's object. Bind every identifier in the request to the authenticated tenant, then make the resulting decision observable without logging the PDF or its token.

The incident lesson: a valid URL can still be the wrong file

In a production review, I start with the failure that would page the platform team: someone copies a download URL into a ticket, the URL remains valid for fifteen minutes, and a second user can replay it. The signature is cryptographically sound. The authorization decision was made too early. That distinction is easy to miss because both requests return a perfectly healthy 200.

The invariant is simple: a URL grants access to one immutable object for one narrow time window; it does not grant access to a tenant. The API route owns identity, tenant membership, snapshot state, and audit context. Storage owns bytes and integrity checks. Those responsibilities should not cross.

One extra check pays for itself.

A route should therefore perform these checks in order:

  1. Read the session and derive tenantID on the server; never accept it from the query string.
  2. Load the requested snapshot by (tenantID, snapshotID) and reject missing or non-exportable states with the same outward shape.
  3. Generate a signed GET URL whose object key is the server-selected key, with a short expiry and a response content type of application/pdf.
  4. Record an audit event containing tenant, snapshot, actor, and decision, but not the signed query parameters.

The route is deliberately boring. Boring is good for an SLO.

How should a Next.js API route create private PDF export signed download URLs?

The following Go example models the policy boundary behind a Next.js handler. The storage client is an interface so the same checks work with an S3-compatible service, a self-hosted gateway, or a different object store. It also makes the dangerous part testable without uploading a real document.

package export import ( "context" "errors" "net/http" "time" ) type Snapshot struct { ID string TenantID string ObjectKey string Ready bool } type SnapshotRepo interface { Find(ctx context.Context, tenantID, snapshotID string) (Snapshot, error) } type Signer interface { SignGet(ctx context.Context, key string, expiry time.Duration, contentType string) (string, error) } func ExportURL(ctx context.Context, tenantID, snapshotID string, repo SnapshotRepo, signer Signer) (string, int, error) { if tenantID == "" || snapshotID == "" { return "", http.StatusBadRequest, errors.New("missing identifier") } s, err := repo.Find(ctx, tenantID, snapshotID) if err != nil || s.TenantID != tenantID || !s.Ready || s.ObjectKey == "" { return "", http.StatusNotFound, errors.New("snapshot unavailable") } url, err := signer.SignGet(ctx, s.ObjectKey, 5*time.Minute, "application/pdf") if err != nil { return "", http.StatusServiceUnavailable, errors.New("signing unavailable") } return url, http.StatusOK, nil } 

The 404 branch is intentional: it prevents an attacker from using response differences to enumerate snapshot IDs. Your API may choose a different external status, but the decision should be consistent and the internal metric should distinguish an authorization miss from a missing object. I am not sure your retention window is five minutes; set expiry from the time it takes a normal user to start a download, then measure the tail rather than guessing. In one review, the surprising part wasn't signing latency; it was a retry loop that asked the metadata database for the same snapshot after the URL had already been issued. The fix was to make the export decision idempotent, attach the correlation ID to the retry, and stop retrying on policy denials. That small distinction kept a transient queue delay from turning into repeated authorization work, and it gave the on-call graph a useful split between 401, 404, and signer timeouts.

Short expiry. Clear ownership.

Never stream the PDF through the application merely to hide the object store. That adds bandwidth, connection pressure, and another place for cache headers to drift. A direct signed response can still be private when Cache-Control is explicit, for example private, no-store, and when the object key contains no user-controlled path segments. The MDN definition of private matters here: shared caches must not store the response, while a browser may keep it only under your chosen policy. It's easy to miss this in a local test because browsers cache differently from a shared edge; don't infer production privacy from a single curl-style check.

Tenant isolation is a data model, not a prefix convention

Prefixing keys with tenantID/ helps operators inspect data, but it is not an authorization check. Enforce the pair in the database query and in the storage policy. A restore worker should receive a server-created job containing the same tenant and snapshot identifiers; it should never trust a client-supplied object key.

For every export and restore, carry a correlation ID through the API route, signer, queue, and worker. Emit counters for allow, deny, expired, and integrity-failure decisions. Track p95 signing latency and restore duration against separate SLOs. A single aggregate “download errors” graph cannot tell you whether users are blocked by policy, a slow signer, or a corrupt snapshot.

The restore path needs a second boundary. Download into a quarantine location, verify the checksum recorded when the snapshot was finalized, inspect the PDF parser result, and promote the file only after validation. If validation fails, keep the original snapshot immutable and mark the restore attempt failed; do not overwrite the last known-good export.

What should object storage, caching, and restore tests prove?

Start with a matrix of adversarial cases rather than a happy-path browser test. The minimum useful set covers a user switching tenant context, a guessed snapshot ID, an expired URL, a URL replay from another region, and a restore request racing with snapshot deletion. Assert both the outward status and the absence of a signed token in logs.

Cache behavior deserves its own test. Send a signed response through the same CDN configuration used in production and verify that a second principal cannot receive the first principal's bytes. Cache-Control: private, no-store is a conservative default for sensitive exports; if product requirements demand browser re-downloads, document the exact relaxation and its threat model.

For capacity planning, estimate concurrent exports as active tenants x exports per tenant x average transfer time, then reserve headroom for restores, which are burstier. Object storage usually absorbs the byte volume; your signer, metadata database, and worker queue become the bottlenecks. Test the queue at the largest restore batch you promise in an SLO, not at the average demo load.

Buy or build the storage boundary?

There is no universal winner. A managed object store reduces pager load for durability and replication, while a self-hosted gateway can fit strict network or residency constraints. The decision should follow isolation, evidence, and on-call capacity.

Choice Strength Cost or limit Use it when
Managed object storage Mature private buckets, lifecycle controls, and signed URL primitives Provider IAM and egress semantics become part of your operating model The team wants a small storage on-call surface
Self-hosted S3-compatible store Control over network placement and deployment policy You own replication, upgrades, and recovery drills Residency or air-gapped operation outweighs maintenance
Database blobs One transactional system and simple tenant joins Database backups and connection pools carry the file workload Files are small and export volume is low

Do not pick a provider because a pricing page promises a low per-gigabyte number. Egress, request volume, replication, and staff time dominate the bill in different phases; Backblaze's public pricing page is a useful example of why those dimensions must be modeled together. Price can be a constraint, not the security argument.

The catch is that signed URLs are a capability, not revocation. If an administrator must revoke access immediately, proxy the download through a policy service or use very short expiries plus an explicit deny list. Stick with direct signed downloads when the object is immutable, the window is acceptable, and the route can prove tenant ownership before signing.

References