DEV Community

LiamFoster1844
LiamFoster1844

Posted on

Private Signed URLs for AI-Generated Images: A Node.js SaaS Governance Checklist

The constraint that changes this storage decision is governance: a support customer must be able to retrieve the right generated report without turning an object key into an authorization token. Short answer: keep report images private, make the application prove tenant ownership, and issue a short-lived signed URL only after that check. Evaluate US or EU placement, S3-compatible behavior, recovery controls, and on-call work as evidence in a release checklist, not as a feature-list contest.

Can teams test private object storage for generated images in a SaaS?

For an authenticated customer-support report, there should be a durable chain from request to object: session identity, tenant, report ID, object key, content type, size, and retention state. The database owns the relationship. A prefix such as tenants/t-184/reports/r-9021/summary.png helps operators understand storage, but it is not an authorization mechanism, and a user-controlled path must never be parsed to infer ownership.

The useful review question is simple: can an engineer explain why this caller may read this object without looking at the URL? If the answer is no, the design has put policy in the wrong place. A gallery endpoint that signs before completing the tenant lookup may pass a demo, then disclose another customer's image when someone guesses a report ID. No clever key naming fixes that.

Keep the service credential on the server. The browser can receive temporary read authority; it should not receive the credential that can mint authority for other objects. That boundary matters during support investigations too, because signed query strings and credentials do not belong in logs.

Keep it private.

What rollout evidence belongs in a rehearsal?

Treat “US or EU” as an input to a written control, not as a region label you vaguely remember from a dashboard. Record where objects live, where report generation runs, where customer requests are served, whether support staff may access the data, and which transfers are permitted. Then test the actual subset of the interface: private upload, metadata lookup, signed download, deletion, and multipart completion if large images require it.

The language choice does not alter the trust boundary. Node.js can authenticate the request and call a presigning library, while the storage adapter exposes only the small operation the application needs. The code below is deliberately a generic contract. It keeps the authorization decision ahead of signing and leaves provider-specific details behind the adapter.

package reports

import (
    "context"
    "errors"
    "net/url"
    "time"
)

var ErrForbidden = errors.New("report is not available to this tenant")

type Report struct {
    ID        string
    TenantID  string
    ObjectKey string
}

type Store interface {
    SignGet(ctx context.Context, key string, lifetime time.Duration) (*url.URL, error)
}

type Reports interface {
    Find(ctx context.Context, reportID string) (Report, error)
}

func DownloadURL(ctx context.Context, tenantID, reportID string, reports Reports, store Store) (*url.URL, error) {
    report, err := reports.Find(ctx, reportID)
    if err != nil {
        return nil, err
    }
    if report.TenantID != tenantID {
        return nil, ErrForbidden
    }
    return store.SignGet(ctx, report.ObjectKey, 5*time.Minute)
}
Enter fullscreen mode Exit fullscreen mode

Five minutes is a policy example, not a promise. A longer lifetime reduces repeat signing but extends exposure after a session change; a shorter one increases API traffic and may expire during a slow download. Tie the lifetime to report sensitivity, expected transfer duration, and the revocation promise. A signed URL does not provide instant revocation by itself.

For browser uploads, add CORS to the test plan. Cross-origin requests depend on the allowed origin, methods, and headers, so a presigned upload still needs a browser policy the storage endpoint will honor. The MDN guidance in References is the standards starting point; the production origin still needs an end-to-end test. Use a server-mediated upload when the policy cannot be expressed and verified.

What data governance belongs in the storage contract?

Capacity planning starts with ranges: reports per day, images per report, median and maximum object size, reads per report, link requests per session, retention days, and cleanup lag. Model a burst separately from the average. A pretty monthly estimate that ignores a popular gallery or a generation retry is not a capacity plan.

Define an access SLO for authorized requests receiving a usable link, then separate SLOs for report generation, deletion, cleanup, and any promised revocation behavior. A reachable bucket is not evidence that a customer can retrieve the correct report. Measure link issuance latency, download outcomes, 429 responses, expired-link complaints, orphan age, and failed deletions; keep tenant and report identifiers available for diagnosis without logging signed query strings.

Pricing belongs after those constraints. Storage bytes, requests, and transfer should be calculated from the capacity sheet, and a published pricing page is evidence for one billing model rather than a portable estimate for every service. I’m not sure which cost line will dominate until object count, read frequency, and retention are measured. Your mileage may vary.

Measure it.

Control Evidence to collect Decision consequence
Tenant isolation Cross-tenant request receives no signed link Reject any design that lets storage decide application ownership
Residency Documented object and processing locations Reject a convenient region that violates the data boundary
Compatibility Tests for upload, metadata, signing, delete, and retries Define “compatible” as the tested subset, not a slogan
Recovery Versioning, retention, or an independent copy against the objective Add a recovery path or choose another capability set
Operations SLOs, metrics, cleanup ownership, and capacity ranges Reject an interface the team cannot operate

What failure does the SLO promise actually cover?

Private signed access is not suitable when the product needs permanent public image URLs, static-site hosting, or anonymous CDN-style delivery. Keep the report store private and add a separate delivery layer with its own cache invalidation and abuse controls. Switching a private bucket to public read is not rollback; it changes the access model.

It is also the wrong fit when the team cannot meet its own recovery objective with the selected service controls. Unique report IDs reduce accidental key reuse, but they do not make an overwrite recoverable. If versioning or object-lock semantics are required and unavailable, add an independent recovery copy or select a service whose controls satisfy the objective. Private delivery protects reads, not writers.

The trade-off is deliberate: signed delivery preserves tenant authorization, but it adds link issuance, expiry policy, and cleanup work that anonymous delivery does not. That limitation is acceptable for authenticated reports and a poor fit for a public image catalog.

When is this delivery shape the wrong fit?

In a non-production tenant, create a known report, upload its image through the server path, request it as the owning user, and fetch the returned link without exposing a service credential. Verify that another tenant receives no link, a deleted report cannot mint a new link, and an expired link stops working. Test a small image and the largest permitted image; assumptions often fail at the boundary. Repeat the checks after a simulated region or provider change, because the migration path is part of the access-control design: the copied object must still resolve to the authoritative report record, the destination must meet the same residency rule, the counts and hashes must agree, the read-path SLO must hold under the expected burst, and the application must be able to stop minting new links without making the old private data public.

Add a race test with two workers targeting one report. Decide whether the second writer is rejected, returns the existing result, or creates a new version. Make that policy visible in database state and metrics. A silent last-writer-wins result is a product decision disguised as storage behavior.

Rollback happens at the application boundary: stop issuing new signed links, pause or drain writers, preserve report-to-key records, and keep the bucket private while investigating. If a provider or region must change, copy objects from authoritative records, verify counts and hashes, exercise the destination read-path SLO, and switch link issuance only after that evidence exists. Keep the old read path for the documented rollback window.

The decision rule is narrow: choose the tested interface and residency, recovery, and operational controls that meet the written requirements. Familiar SDK ergonomics cannot compensate for missing evidence.

References

Top comments (0)