Storing Uploaded Photos in Postgres bytea, Served Only to Admins
Why RPOMS AI keeps inspection photos in a PostgreSQL bytea column instead of a bucket, how they are served only through an admin-guarded route, and when this stops being a good idea.
The usual advice, and why I did not follow it here
This is for anyone deciding where a small web app should keep user-uploaded images. The standard advice is object storage: put files in a bucket and keep a key in the database. For RPOMS AI, a router inspection tool where anyone can upload a photo and only administrators may ever see it, I put the photos in PostgreSQL instead.
The first version did have a storage boundary written in S3 vocabulary, with an S3-compatible provider and an in-memory one. The rebuild on 20 September 2026 removed both. Two reasons decided it. First, the photos are private by nature. A bucket is one misconfigured policy away from public, and the whole point of this data is that no one but an administrator sees it. Second, every photo belongs to exactly one inspection row, and keeping both in one database means one transaction, one backup and one access-control story.
The table
CREATE TABLE IF NOT EXISTS photos (
id text PRIMARY KEY,
sha256 text NOT NULL,
content_type text NOT NULL,
width integer NOT NULL,
height integer NOT NULL,
byte_size integer NOT NULL,
data bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_photos_type CHECK (content_type IN ('image/jpeg','image/png')),
CONSTRAINT ck_photos_size CHECK (byte_size > 0 AND byte_size <= 6291456),
CONSTRAINT ck_photos_dims CHECK (width > 0 AND height > 0)
);The constraints are the database refusing nonsense, whatever the application does: only two content types, a 6 MB ceiling, and positive dimensions. Each inspection row references its photo with ON DELETE SET NULL. A photo can therefore be deleted for retention without losing the inspection record, its verdict or its full reasoning trace.
What goes into data is never the uploaded file. It is a JPEG re-encoded on the server from decoded pixels, at most 1600 px on the long edge and quality 85, so it carries no EXIF or GPS and has a bounded size. The details are in Hardening a Public Photo Upload. Whether a photo is kept at all is an admin setting, and it defaults to on.
Serving it: one guarded route, no public URL
There is no URL that serves a photo without a permission check. Every read goes through one route handler:
export async function GET(request: Request, ctx: RouteContext<"/api/admin/photos/[id]">) {
const g = await guard(request, "admin.access");
if (isGuarded(g)) return g.response;
const { id } = await ctx.params;
if (!/^pho_[a-f0-9]{24}$/.test(id)) return new NextResponse("Not found", { status: 404 });
const photo = await getPhoto(id).catch(() => null);
if (!photo) return new NextResponse("Not found", { status: 404 });
return new NextResponse(Buffer.from(photo.data), {
headers: {
"content-type": photo.contentType,
"cache-control": "private, max-age=300",
"x-content-type-options": "nosniff",
"content-security-policy": "default-src 'none'",
},
});
}Each header has a job. private keeps shared caches and CDNs from storing a copy. nosniff stops a browser from reinterpreting the bytes as something other than the declared image type. default-src 'none' means that even if a stored file were somehow opened as a document, it could load and run nothing. The id is checked against its exact format before any database call.
An unauthenticated or non-admin request gets 404, not 401 or 403. The admin API does not advertise that it exists. An architecture test checks that every admin route handler begins with this guard.
Closing the side door on Supabase
The database is Postgres on Supabase, which also exposes tables through its Data API using the project's public keys. The application connects directly as the table owner and never uses that API, so the API is purely a risk. Row Level Security is enabled on every table with no policies at all. Anonymous and authenticated API roles can then read nothing, while the owner connection is unaffected. That is one of the patterns compared in Two Ways to Use Supabase Row Level Security, and the failure mode on the other side is in Every Policy Was Correct, and Every Policy Was Inert.
When this stops being a good idea
The project documentation estimates each stored photo at roughly 150 to 400 KB. A free-tier Supabase database is 500 MB, so that is room for about 1,500 to 3,000 retained photos, less whatever the rest of the schema uses, before cleanup or a plan change is needed. That ceiling is fine for an internal tool in a testing phase. It would not be fine for a high-volume service.
Other costs come with it. Every photo read passes through a function invocation instead of a CDN. Backups grow with the photo count. Large bytea values also make a careless SELECT * on the photos table expensive, which is one more reason to keep image bytes behind a single read path. If RPOMS AI ever handled real line volume, moving to a private bucket with signed, short-lived URLs would be the natural next step. It has not reached that point: there is no model active in production, and the tool has not been used on real line photos.
The work behind this
Related
Keep reading
Hardening a Public Photo Upload: Magic Bytes, Header Dimensions and Stripping EXIF Twice
An anonymous endpoint that decodes images is easy to abuse. How RPOMS AI re-encodes photos in the browser, trusts none of it on the server, and never stores the uploaded bytes.
Two Ways I Use Supabase Row Level Security: Deny-All Behind a Server, or Per-Owner Policies
Across five projects, RLS does one of two jobs: close the Data API while a trusted server does the work, or let Postgres enforce who owns each row. When to pick which.
Rate Limiting a Public Endpoint on Serverless, Keyed by an HMAC of the Address
Two layers for an anonymous upload endpoint on Vercel: an honest in-memory burst limiter, and a durable hourly count in Postgres keyed by an HMAC so the address is never stored.