Back to engineering

Engineering

Keeping a client photo gallery private: row scoping, a private bucket, and a route that 404s on any mismatch

The Bookatu engineering team7 min read

How Bookatu keeps each photographer's client galleries private in a multi-tenant app: org plus customer row scoping in Postgres with Drizzle, a private blob store, and a serving route that checks ownership and returns 404 for anything you do not own.

The short version

  • A private client gallery is a textbook target for broken access control. The fix is boring on purpose.
  • Every row is scoped by two keys: the tenant (org) and the customer. Queries filter on both, always.
  • The image bytes live in a private bucket. Nothing is served by a public URL.
  • One serving route checks ownership and returns 404 for anything you do not own. It never confirms that an id exists.
  • OWASP puts this under A01 Broken Access Control. We treat it as the first thing to test, not the last.

Bookatu is one booking platform with per-industry verticals: salon, spa, personal trainer, sports coach, fitness studio, restaurant, and photographer. The photographer vertical ships private per-client photo galleries. The photographer uploads high-res files. We auto-generate a low-res (1080px) preview with a baked-in watermark and stripped EXIF, and the client sees only that preview until the included and paid set is settled. Clients reach their gallery through a long-lived passwordless magic link.

That feature is a wedding shoot, a newborn session, a set of headshots. It is exactly the kind of data that must never leak to another client or another studio. This post is the access-control spine behind it. None of it is clever. That is the point.

The vulnerability we are designing against

OWASP describes an Insecure Direct Object Reference (IDOR) as exposing an internal reference, like a database id, without checking that the caller is allowed to use it. It sits under A01 Broken Access Control, the top category in the OWASP Top 10. The failure mode is simple: a gallery loads at /g/1041, someone tries /g/1042, and the server hands back a photo that belongs to a different client. The server trusted the client to ask for the right id.

The OWASP IDOR prevention guidance is direct about the fix. Scope every data-access query to the current user, and do not rely on the reference alone. Their example reads SELECT ... WHERE id = :id AND user_id = :current_user. We follow that shape, with two scoping keys instead of one.

Two keys on every row

Multi-tenant rows carry a tenant key so one studio can never read another studio's data. AWS makes the same point for pooled Postgres: isolation belongs in the data layer, enforced on every table that holds tenant data, not sprinkled across handlers. A gallery adds a second key for the client, so one client of a studio cannot read another client of the same studio. Here is the shape, with names simplified for illustration, and the only query pattern we allow.

ts
// schema (names generic for illustration)
export const photos = pgTable('photos', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').notNull(), // the studio (tenant)
  clientId: uuid('client_id').notNull(), // the client
  objectKey: text('object_key').notNull(), // private bucket path
  unlocked: boolean('unlocked').notNull().default(false),
}, (t) => ({
  // tenant key leads the index so scoped reads stay fast
  scopeIdx: index('photos_tenant_client_idx').on(t.tenantId, t.clientId),
}));

// every read is scoped by BOTH keys, never by id alone
async function loadPhoto(db: DB, scope: Scope, photoId: string) {
  const [row] = await db.select().from(photos).where(and(
    eq(photos.id, photoId),
    eq(photos.tenantId, scope.tenantId),
    eq(photos.clientId, scope.clientId),
  )).limit(1);
  return row ?? null; // null means "not yours", treated the same as "absent"
}

The scope object is never read from the request body or the path. It comes from the verified magic-link session for the client, or from the authenticated studio account. The id in the URL only narrows the result inside an already-scoped set. AWS notes that a leading tenant column on the index keeps these scoped reads cheap, so the safe query is also the fast query.

The bytes live in a private bucket

No photo is served by a public object URL. The bucket is private, and the object key never appears in client-facing HTML. When a client is entitled to a file, the app mints a short-lived presigned URL at request time. Cloudflare's R2 docs frame presigned URLs the right way: they grant time-limited access to a specific object to someone who would not otherwise have credentials. Short expiry means a copied link dies quickly, and a leaked watermarked preview is still only a watermarked preview.

Entitlement is the gate. Clients get a presigned URL for the 1080px watermarked preview. The full-resolution key is only signed once the included free count plus any paid extras are settled. The unlock decision is a database read scoped by the same two keys, so storage never makes an authorization decision on its own.

One route, ownership-checked, 404 on any mismatch

All image access funnels through a single serving route. It resolves the session, scopes the read, and refuses to distinguish 'this id is not yours' from 'this id does not exist'. Both return 404. The CGI engineering guidance calls this resource hiding: a 403 confirms the resource is real, which lets an attacker map your data by scanning ids. A uniform 404 reveals nothing.

ts
// The private photo endpoint. The original is never a public asset.
export async function GET(req: Request, { params }: Ctx) {
  const scope = await resolveScope(req); // magic-link or studio session
  if (!scope) return new Response(null, { status: 401 });

  const photo = await loadPhoto(db, scope, params.id);
  // not found AND not-yours both land here. Same body, same status.
  if (!photo) return new Response(null, { status: 404 });

  const wantsFull = new URL(req.url).searchParams.get('res') === 'full';
  const key = wantsFull && photo.unlocked ? fullKey(photo) : previewKey(photo);
  if (!key) return new Response(null, { status: 404 }); // full not settled yet

  const url = await signGet(key); // short-lived presigned URL
  return Response.redirect(url, 302);
}

Two details matter. First, a missing row and an unauthorized row take the identical branch, so timing and response shape stay constant. Second, the full-resolution path checks photo.unlocked before signing, so settlement is enforced on the server, not in the client UI. The id in the URL is a non-sequential UUID, which OWASP recommends as defense in depth. It is not the control. The scoped query is.

Access-control sketch

  • Identify: verify the magic-link or studio session. No session, return 401.
  • Scope: build {tenantId, clientId} from the session only, never from the request.
  • Authorize: read the row filtered by id AND tenant AND client. No row, return 404.
  • Entitle: for full resolution, require unlocked = true, else 404.
  • Serve: mint a short-lived presigned URL for the private bucket and redirect.
Ensure that database queries and data access layers are scoped to the current user's permissions, for example SELECT * FROM orders WHERE user_id = :current_user AND id = :order_id. (OWASP IDOR prevention guidance.)

What we test

Cross-tenant isolation is a silent failure. A broken scope does not throw. It just returns the wrong rows. So we test it like AWS suggests for RLS: automated tests that act as client B and try every route with client A's ids, then assert 404 every time, with an identical body. We assert the full-resolution key is never signed while a gallery is unsettled, and that the private object key never appears in any response payload. Those tests run on every change to the gallery code.

The lesson is that privacy here is not a feature you bolt on. It is the default query shape, the default bucket setting, and the default error code. Two keys on every row, a private bucket, and a route that gives nothing away. Boring, repeatable, and the thing a client's wedding photos actually depend on.

Sources

  • OWASP Insecure Direct Object Reference Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html
  • OWASP: Insecure Direct Object Reference (IDOR): https://owasp.org/www-community/attacks/insecure_direct_object_reference
  • AWS: Multi-tenant data isolation with PostgreSQL Row Level Security: https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/
  • Cloudflare R2: Presigned URLs: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
  • When Should You Return 404 Instead of 403 (CGI Insights): https://www.insights.cgi.com/blog/when-should-you-return-404-instead-of-403-http-status-code
multi-tenancyaccess-controlpostgresdrizzlesecurityphotographer

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs