Private image delivery in an EU SaaS: signed URLs beat blob columns

Serving private photos and thumbnails from a bucket: batch-signing a gallery page, picking a TTL that still caches, and checking where the bytes really sit.

Put the pixels in a private bucket and the facts about them in Postgres, then hand each <img> tag a signed URL minted at render time. Infrai signs those links for free through POST /v1/storage/object/presign/{bucket}/{key}, with a time-to-live you choose anywhere in [1..604800] seconds. A bytea column only competes below a few kilobytes, and a user’s photo — even the thumbnail — is rarely that small.

The upload half of this problem is well covered elsewhere. The delivery half is where designs quietly go wrong: a gallery of 40 thumbnails means 40 signatures per page view, every one of them a cache-busting URL unless you think about it, and Infrai’s own region handling has a caveat worth checking before anyone in sales says the word GDPR.

The two-leg design, stated once

Postgres holds object_key, width, height, owner_id and a state column. The bucket holds bytes under a key you derived server-side. Nothing in the browser ever sees a bucket name it could iterate, and no request for an image reaches your API server at all — the signature is the authorisation, and the bytes flow straight from storage to the tab.

That last point is also the cost argument, and it surprises people.

Reading an object through the API (GET /v1/storage/object/get/{bucket}/{key}) is a billable call. Reading it through a signed URL isn’t a call at all — the vendor serves it, you’re billed for the bytes as egress and nothing per request. For a photo gallery that difference is the whole bill.

Sign the page in one pass

A per-image round trip to your API is the naive version, and it turns a gallery into a waterfall. Sign the whole page server-side instead, in parallel, and ship the URLs inside the JSON the page already fetches:

import express from "express";
import { Pool } from "pg";

const API = "https://api.infrai.cc";
const BUCKET = "kb-gallery-0726";
const TTL_SECONDS = 300;

const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();

async function signGet(key) {
  const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ op: "get", expires_seconds: TTL_SECONDS }),
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) throw new Error(json?.error?.code ?? `HTTP ${res.status}`);
  return json.data;
}

app.get("/api/gallery", async (req, res) => {
  const ownerId = req.header("x-user-id");
  if (!ownerId) return res.status(401).json({ error: "unauthenticated" });

  try {
    const { rows } = await pool.query(
      "SELECT thumb_key, width, height FROM photos WHERE owner_id = $1 ORDER BY created_at DESC LIMIT 40",
      [ownerId],
    );
    const items = await Promise.all(rows.map(async (row) => {
      const signed = await signGet(row.thumb_key);
      return { url: signed.url, expires_at: signed.expires_at, width: row.width, height: row.height };
    }));
    res.set("Cache-Control", "private, max-age=120").json({ items });
  } catch (err) {
    console.error("gallery signing failed", err);
    res.status(502).json({ error: "could not build gallery" });
  }
});

app.listen(3000);

The WHERE owner_id = $1 is doing the security work. A signature says “this URL was issued”, never “this person may look” — that decision belongs in the query above it, and if you skip it you’ve built an enumerable photo album with extra steps.

TTL is a caching decision as much as a security one

Every fresh signature is a new URL, and a new URL is a guaranteed cache miss. Sign the same thumbnail twice in ten seconds and the browser downloads it twice.

The fix is to quantise. Round expires_seconds so that all requests inside the same window produce an identical URL — five-minute buckets are a reasonable default for a gallery, an hour for a profile page that barely changes. Beyond that, weigh how long a leaked link stays useful: seven days is the ceiling the API allows, and anything past a few hours in a shared workspace is a link somebody will paste into a chat thread.

When the window closes, the failure is unmistakable:

<?xml version='1.0' encoding='utf-8' ?>
<Error>
  <Code>AccessDenied</Code>
  <Message>Request has expired</Message>
  <ServerTime>2026-07-26T00:36:44Z</ServerTime>
</Error>

Ask for more than the ceiling and you don’t get a link at all — STORAGE_INVALID_TTL, ttl_seconds 1209600 out of range [1..604800], at request time rather than at click time.

Thumbnails get their own keys and their own lifecycle

Derived images are disposable; originals aren’t. Keep them in separate prefixes so retention can differ, then let the bucket expire the cheap half:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-gallery-0726 \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"thumbs/","expire_days":90}]}'

A regenerable thumbnail that nobody has opened in 90 days is pure rent. Listing the prefix is free, so a weekly job can tell you how much of it is live:

curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-gallery-0726?prefix=u/usr_412/2026/07/" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "key": "u/usr_412/2026/07/photo-8831.thumb.webp", "size_bytes": 24, "etag": "31c1f68907dac7712fb1fb09b3b00ec2" },
      { "key": "u/usr_412/2026/07/photo-8831.webp", "size_bytes": 24, "etag": "ef9b2019d5d042ea88f42d4613052bcb" }
    ],
    "next_cursor": null
  }
}

Check where the bytes actually are before promising Europe

Buckets take a region at creation, and GET /v1/storage/bucket/get/{bucket} reads it back. The signed URL’s hostname is the stronger evidence, because it names the vendor endpoint that will serve the file — and in our testing on 26 July 2026 a bucket created with eu-central-1 returned links on an ap-singapore host.

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-gallery-0726/u/usr_412/2026/07/photo-8831.thumb.webp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":300}'

If EU residency is a contractual commitment rather than a preference, that’s a limitation you have to design around: run the image bucket on Amazon S3 in eu-central-1 or a Supabase project pinned to an EU region, and keep the rest of the stack here. It’s an honest boundary, and better found in a curl output than in a data-processing agreement.

Blob column, bucket, or an image service

OptionPrivate by defaultPer-render costWhere it stops being right
Postgres byteaYes, it’s a rowA query and a pinned connectionAnything past a few KB: dumps, WAL and replicas all pay again
Infrai bucket + signed URLYesFree signature, bytes billed as egressHard EU residency, or if you need on-the-fly resizing
Amazon S3 + CloudFront signed cookiesYesCheap at CDN scaleSmall teams: two services and a key-pair rotation to run
Supabase StorageYesSigned URL, similar shapeYou’re not otherwise on Supabase
CloudinaryOptionalTransform-pricedStorage-only workloads — you’re paying for a feature you don’t use

What delivery costs

Presigning, head, list and lifecycle rules are free and rate-limited, and they don’t consume the new-account trial. The billable events are writes and API-mediated reads: verified 26 July 2026, storage.object.put is $0.0001 per call and storage.object.get is $0.0002, plus stored GB-months and egress GB. Serve through signed links and a 40-thumbnail page costs $0 in call fees — just the bytes.

curl -sS https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

Those rates drift downward over time, so read the live value before you build a model on it. What doesn’t drift: the key that signs these thumbnails is the same key that runs the resize worker’s queue, sends the “your export is ready” email, and reports per-tenant spend — one bill instead of four.

References

Browse more storage developer guides