Short answer: make the Next.js API route decide tenant access and regional placement before it asks a storage adapter for a signed download URL; keep the PDF private, and treat the URL as a short-lived bearer credential rather than as an authorization system.
That ordering is the architecture. The browser should present an export identifier, not a bucket, region, or object key. The route loads an application record scoped to the caller's tenant, checks that the export is ready, and only then delegates signing to the storage implementation. This is the useful boundary for a developer-tools product that keeps per-tenant backups and lets an operator restore one selected snapshot.
It sounds obvious. Cross-tenant storage bugs usually enter through the metadata lookup, not through cryptography.
The decision record: authorization owns identity
The system has four invariants:
- Every backup record has one tenant owner and one placement decision, such as
usoreu. - The client can name an export, but cannot choose the tenant, region, bucket, or object key used to retrieve it.
- Object storage remains private; the application creates a signed URL only after authorization succeeds.
- A missing object and an unauthorized export do not become a useful cross-tenant oracle.
The export table therefore needs more than a filename. A practical record contains an opaque export ID, tenant ID, lifecycle state, approved region, bucket reference, object key, content type, and a checksum or size captured after the write. A human-readable name belongs in presentation metadata. It should never be the path the storage layer trusts.
For US and EU placement, choose the bucket at export creation and persist that choice with the export. A request such as ?region=eu is not a compliance control; it is merely untrusted input. The route should compare the stored placement against the tenant policy, and the worker that writes the PDF should use the same policy. If those two paths disagree, the right response is an operational alert and a blocked export, not a clever fallback to another region.
The signed URL protects a transfer window. It does not prove that the PDF belongs to the right tenant, that the object was retained correctly, or that a copy did not cross a regional boundary earlier in the pipeline. Those claims need database constraints, bucket policy, job controls, and audit events.
How should a Next.js API route protect private PDF exports across US and EU object storage?
The route can stay small if its responsibilities are explicit:
- Authenticate the session and establish the caller's tenant.
- Fetch the export by opaque ID within that tenant scope.
- Return a non-revealing state for an unknown, unauthorized, or not-yet-ready export.
- Verify the stored placement and object metadata through the server-side adapter.
- Ask the adapter to mint a short-lived signed download URL.
- Return the URL with a private response policy and record an audit event without logging the URL itself.
The distinction between step two and step four matters. A database row saying ready is an application claim; storage metadata saying the object exists is a data-plane observation. A queue retry can commit one before the other. The download path should represent that honestly as processing or ready, while its external response can deliberately avoid telling an attacker which export IDs exist.
Here is the critical path without pretending that every storage service has the same signing method. The adapter is the only place that knows how the selected backend signs a URL. The policy function knows who may receive it.
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass(frozen=True)
class Export:
export_id: str
tenant_id: str
region: str
object_key: str
state: str
def issue_download(
caller_tenant: str,
requested_export_id: str,
export: Optional[Export],
tenant_region: str,
object_ready: Callable[[str, str], bool],
sign: Callable[[str, str], str],
) -> dict[str, str]:
if export is None or export.export_id != requested_export_id:
return {"state": "not_found"}
if export.tenant_id != caller_tenant:
return {"state": "not_found"}
if export.state != "ready" or export.region != tenant_region:
return {"state": "unavailable"}
bucket_ref = f"private-{export.region}"
if not object_ready(bucket_ref, export.object_key):
return {"state": "processing"}
return {
"state": "ready",
"url": sign(bucket_ref, export.object_key),
}
if __name__ == "__main__":
selected = Export("exp_7f3", "tenant_12", "eu", "backups/exp_7f3.pdf", "ready")
print(issue_download(
"tenant_12",
"exp_7f3",
selected,
"eu",
lambda bucket, key: True,
lambda bucket, key: f"temporary-link-for:{bucket}/{key}",
))
In a real route, the storage credential stays server-side. The adapter should validate the response it receives, impose a bounded timeout, and expose a small application-level result rather than leaking backend-specific errors into the browser. A signed URL should not be written to durable logs, analytics payloads, email events, or the export table. It is a credential, even when its lifetime is brief.
What failure modes should the backup and restore workflow make visible?
The restore workflow is where storage abstractions meet consequences. A selected snapshot should be immutable from the restore request's point of view: resolve the snapshot ID to a stored object key, capture the object's checksum and size, and restore into a tenant-scoped staging area before replacing live data. Never let a browser-supplied filename select the restore target.
There are several ordinary failures worth naming:
| Failure mode | What the system should do | What it must not infer |
|---|---|---|
| Export row exists, object is absent | Keep the export non-downloadable and surface a processing or reconciliation state | That the object is permanently lost |
| Caller and export tenants differ | Return the same safe not-found shape used for an unknown ID | That the export exists for another tenant |
| Region on the row conflicts with tenant policy | Stop the job, alert, and preserve the audit trail | That moving the request to another region is harmless |
| Signed URL expires during a large transfer | Let the client request a new authorized URL | That the object should become public |
| Restore checksum differs | Quarantine the staged restore and require operator review | That a partial restore is good enough |
The last row is intentionally uncomfortable. A successful HTTP response is not proof that a restore is correct. Record the snapshot ID, tenant ID, region, object version or generation when the backend provides one, checksum, actor, and outcome. If the backend lacks version recovery or immutable retention, that is a capability boundary to document before calling the system backup-grade.
Retry policy deserves the same precision. Retrying a read or a URL minting request is usually different from retrying a write. Export creation needs an idempotency key or a stable export ID so a timeout does not create two logical snapshots. A restore needs a job ID and a state machine so a worker restart resumes a known stage instead of applying the same destructive operation twice.
Which storage choice fits this boundary, and when is it unsuitable?
There is no universal winner because the application is choosing a control surface as much as a byte store. Compare the properties that tenant isolation actually consumes:
| Option | Useful property to verify | Trade-off that changes the decision |
|---|---|---|
| Provider-native object API | Deep policy, lifecycle, retention, and audit integration | More backend-specific code and credentials to operate |
| S3-compatible adapter | A familiar signing model and portable application interface | Compatibility is not identity, retention, replication, or regional equivalence |
| Application proxy for downloads | Centralized authorization and response transformation | Application bandwidth, latency, and failure budget carry the file bytes |
| Direct signed downloads | Storage carries the transfer after one authorization decision | Revocation is limited by URL lifetime and link disclosure |
The catch is that direct signed delivery is unsuitable when every byte must pass through application-side redaction, watermarking, malware scanning, or per-request policy evaluation. Use a proxy or a staged transformation for those cases. It is also a poor fit when legal retention requires an immutable write-once policy that the chosen storage layer cannot provide. Keep the native controls or change the storage design; a URL signature cannot manufacture them.
For a developer-tools backup product, I would select the simplest adapter that can prove private access, regional placement, lifecycle behavior, integrity checks, and the required retention policy. I am not sure a single abstraction layer will preserve every provider-specific guarantee; your mileage may vary once legal hold, replication lag, or restore-time recovery objectives enter the requirements. Write those guarantees as tests against each approved backend instead of trusting the word “compatible.”
The operational checklist is part of the API
Before shipping the route, test the policy with two tenants, two regions, an unknown export ID, a processing export, a missing object, an expired link, and a checksum mismatch. Test that a US caller cannot retrieve a EU export even if the export ID is guessed, and that changing a display name cannot change the object key. These are cheap tests. The incident they prevent is not.
Monitor counts and latency by region and outcome, but hash or tokenize tenant identifiers in shared dashboards. Alert on placement mismatches, repeated missing-object observations, unusual URL issuance rates, and restores that remain in staging. Keep request IDs in logs so an audit can join the authorization decision, storage observation, signing event, and restore job without retaining bearer URLs.
Set Cache-Control deliberately on the API response. User-specific responses should not be reusable as public content, and the browser cache should not outlive the authorization window by accident. The exact policy depends on whether the response contains only a URL or a streamed file, so treat it as a reviewed contract rather than a framework default.
The final design rule is modest: identity and policy live in the application; placement and integrity live in the data model and storage controls; signed URLs only connect an already-authorized browser to an already-selected private object. Keep those responsibilities separate and the restore path remains explainable when the happy path stops being happy.
References
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
- https://www.backblaze.com/cloud-storage/pricing
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
- https://cloud.google.com/storage/docs/access-control/signed-urls
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
Top comments (0)