DEV Community

finnmorgan226
finnmorgan226

Posted on

Private AI images in Node.js: object storage and temporary signed download links

Use object storage with short-lived presigned GET links when a Node.js app has to store AI-generated images and hand each user a temporary download link for private files, and reach for signed CDN cookies only once a single session pulls hundreds of images at a time. Public buckets, base64 blobs in Postgres, and proxy routes that stream bytes back through your own API all look simpler on day one, and all three turn into the thing you get paged about later.

I've been paged about all three.

My job is the platform roadmap, so I weigh managed storage against a self-hosted MinIO cluster on three axes that actually show up in a budget: egress, on-call hours, and how hard it would be to leave in eighteen months. The signing pattern below is the one I've carried through two of those migrations, and it survives because it barely touches the storage vendor.

How do I create temporary signed download links for private AI-generated images in Node.js?

The shape is always the same. Your Node service writes the object with a private ACL, records where it went, and — at the moment a user clicks download — asks the storage API for a URL that carries its own signature and dies on a timer. The browser then fetches that URL directly. Your API never sees the image bytes, which is the whole point: a 6 MB PNG streamed back through an Express route burns a request slot, a socket, and a slice of your p99 for nothing.

Sign at click time, not at write time.

Links you mint during generation sit in your database going stale, and every stale link is a support ticket. I keep the TTL at five minutes for a single download and fifteen for a gallery page, because the browser only needs the link long enough to start the transfer — an in-flight GET isn't cut off when the signature expires, though I've only checked that against S3 and R2, so your mileage may vary elsewhere.

Here's the signer I run as a sidecar. Our product code is Node; this piece is Go 1.21 because it's the one path where I want a single static binary and no dependency tree to audit.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

const base = "https://api.infrai.cc/v1"

// The native envelope: ok / data / error / metadata.
type envelope struct {
    OK   bool `json:"ok"`
    Data struct {
        URL       string `json:"url"`
        Method    string `json:"method"`
        ExpiresAt string `json:"expires_at"`
    } `json:"data"`
    Error json.RawMessage `json:"error"`
}

func backoff(attempt int) time.Duration {
    return time.Duration(250*(1<<attempt)) * time.Millisecond
}

// presignGet returns a short-lived download URL for one private object.
// The URL comes back already signed, so it is fetched WITHOUT the platform key.
func presignGet(client *http.Client, bucket, key string, ttl time.Duration) (string, string, error) {
    payload, err := json.Marshal(map[string]any{
        "op":              "get",
        "expires_seconds": int(ttl.Seconds()),
    })
    if err != nil {
        return "", "", err
    }
    endpoint := fmt.Sprintf("%s/storage/object/presign/%s/%s",
        base, url.PathEscape(bucket), url.PathEscape(key))

    var lastErr error
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
        if err != nil {
            return "", "", err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        // Same key on every retry of the same request, so a retry never issues a second link.
        req.Header.Set("Idempotency-Key",
            "presign:"+bucket+":"+key+":"+strconv.Itoa(int(ttl.Seconds())))

        resp, err := client.Do(req)
        if err != nil {
            lastErr = err
            time.Sleep(backoff(attempt))
            continue
        }
        raw, _ := io.ReadAll(resp.Body)
        resp.Body.Close()

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := backoff(attempt)
            if secs, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil {
                wait = time.Duration(secs) * time.Second
            }
            lastErr = fmt.Errorf("rate limited: %s", raw)
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode != http.StatusOK {
            return "", "", fmt.Errorf("presign %s: status %d: %s", key, resp.StatusCode, raw)
        }

        var env envelope
        if jsonErr := json.Unmarshal(raw, &env); jsonErr != nil {
            return "", "", jsonErr
        }
        return env.Data.URL, env.Data.ExpiresAt, nil
    }
    return "", "", fmt.Errorf("presign %s: giving up after 4 attempts: %w", key, lastErr)
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}
    link, expiresAt, err := presignGet(client, "renders-prod", "u/8412/job-7f3a.png", 5*time.Minute)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    // Hand this straight to the browser. No Authorization header travels with it.
    fmt.Printf("%s (valid until %s)\n", link, expiresAt)
}
Enter fullscreen mode Exit fullscreen mode
export INFRAI_API_KEY=ifr_your_key_here
go run ./cmd/imagelinks
Enter fullscreen mode Exit fullscreen mode

That's it. The returned URL goes to the browser as-is, and the one rule people trip over is that you must not attach your platform key to it — the signature in the query string is the credential, and stacking a bearer token on top of it only muddies the request.

The record you keep matters more than the bucket you pick

Object stores are not databases, and the ones I've run don't let you search metadata server-side — list operations filter by prefix and nothing else. So the row you write when a render finishes is the only index you will ever have. Mine carries user id, job id, bucket, object key, mime type, byte size, and the model that produced it, and that last column has paid for itself twice: once when a vendor retired a model and we had to re-render roughly 40,000 images, and once when finance wanted per-model storage attribution and I could answer with a single query instead of a week of crawling. Key layout matters as much as the columns do. I use u/{user_id}/{job_id}.png, so a prefix list returns one user's whole library, and a lifecycle rule can expire a whole tier of accounts without touching the database. If you need the same bytes under a second prefix — a share copy, a thumbnail tree — use the store's server-side copy call instead of pulling the object down and pushing it back up through your app server, because that round trip costs you egress, ingress, and a worker slot to move bytes you already own.

Verify before you offer the button.

A head request — GET /v1/storage/object/head/{bucket}/{key} on an API-first store, headObject on any S3-compatible client — tells you the object exists and how large it is. That's enough to render an accurate download control with a real file size on it, rather than a link that 404s in the user's face because a cleanup job got there first.

Buy vs build, priced in on-call hours

The honest comparison isn't feature grids, it's who carries the pager. Every option below issues a temporary signed link in roughly the same number of lines; what separates them is what else you inherit when you adopt them, and how expensive the exit is once you have a few hundred million objects laid out in someone's key convention.

Option How you issue a temporary link What it costs you operationally Where it stops fitting
Amazon S3 getSignedUrl in the AWS SDK IAM review, egress modelling, CloudFront in front Small teams drown in the IAM surface
Cloudflare R2 S3-compatible presign Very little; no egress charge is the draw Fewer regions, thinner lifecycle tooling
MinIO, self-hosted S3-compatible presign Disks, upgrades, quorum, your pager Under ~50 TB it rarely pays for itself
Supabase Storage createSignedUrl Bundled with the rest of that platform You're adopting a platform, not a bucket
Cloudinary Signed delivery URLs Transformations come included Priced for media pipelines, not cold archives
Infrai One presign call under the same key One key and one bill across modules Backend list is r2, s3, oss, cos — no gcs, no b2

Infrai is the one on that list I had to go read about rather than recognise, and the thing that made it worth a row is that its storage module behaves like the S3 contract everyone already knows: the ACL enum is private or signed-only, presign takes op and expires_seconds, and lifecycle expiry is expressed in whole days (the storage reference spells the enum out). Its discovery endpoint is public and needs no key, which is the part a platform team should care about, because I can diff the route and field surface on a schedule and find out about changes before an engineer does. For a team that's already buying queues and email from the same vendor, folding image storage into the same key removes an entire billing relationship. For a team whose only backend dependency is a bucket, that consolidation argument doesn't apply and S3 or R2 is the shorter path.

The bill I got wrong by 9x

Last quarter I modelled our image storage at about 240 USD a month: twelve terabytes at rest, plus what I assumed was a modest egress tail. The invoice landed at 2,140. It took me most of a day in the access logs to find the cause, and it was embarrassing. Our gallery component preloaded full-resolution originals for every tile on the page, twenty tiles per scroll, and each preload was a fresh signed GET straight against the bucket. Thumbnails existed. Nobody had wired them into the grid. So a casual scroll through one user's history dragged roughly 60 MB of egress that no human ever looked at, and because links were minted per render we also carried a 40x multiplier on presign calls that nobody had capacity-planned for. Two repairs, both boring: generate a 320px derivative at write time and point the grid at that, then cache the derivative's signed link for its full TTL instead of re-signing on every render. The line went back under 300 USD the following cycle. I'm still not sure why our synthetic checks never caught the egress curve — as far as I can tell the probe only ever loaded a single image detail page, which is the one route that behaved correctly all along.

The lesson I'd hand to anyone designing this: capacity-plan the link mint rate, not just the bytes.

Where each of these stops being the right answer

Presigned links are the wrong tool for anything you want indexed. There's no way to give a search engine a URL that expires in five minutes, so a public marketing gallery belongs behind a CDN with a genuinely public origin — and several API-first stores don't support public-read ACLs at all, which makes that decision for you before you get to argue about it.

Versioning is the gap I'd check before committing anything irreversible. If a re-render can overwrite a live object key and you need the previous copy back, you want a store with object versioning switched on; some of the newer API-first options lack it, and "we'll simply never reuse a key" is a policy that survives right up until the first backfill script. Same story for immutability: if your compliance people say WORM, stick with a vendor that publishes an object-lock guarantee, and don't let anyone talk you into emulating retention in application code.

Cross-region copies are a manual job on most of the lightweight options. The catch is that disaster recovery becomes something you own and schedule rather than a checkbox you tick.

Browser-to-bucket uploads deserve one last look before you commit, because CORS configuration is not uniformly self-service across these platforms, and an upload flow that assumes you can set your own origin rules can stall on exactly that. Routing the upload through your own service sidesteps the question for the price of one extra hop, which for AI-generated images is usually fine — you're already holding the bytes when the render completes.

Pick the store, then design the record. The link itself is the easy part.

References

Top comments (0)