# How to Upload and Serve Images From an App Without S3 [Full Repo]

A web page uploads a photo with one POST, gets back a public CDN URL, and references it from a row with an owner. We measured every size from 10 KB to 50 MB and found that the row's permissions guard the row, not the bytes.

URL: https://www.back4app.com/blog/upload-and-serve-images-without-s3
Published: 2026-10-05 · Tested: 2026-09-24
Tested on: Backend: Back4app, free plan, USA East · Web: one HTML page + vanilla JavaScript · Terminal: curl on macOS · Browser runs: headless Chrome 154
Publisher: Back4app Engineering

Your app needs a profile picture, or a photo on every listing, and the first search result says: create a bucket, write an IAM policy, generate a presigned URL from a server, then upload from the browser to the bucket and write the key back to your database. Four moving parts for one `<input type="file">`.

A managed backend has a **files endpoint**: you `POST` the raw bytes to `/files/<name>` with a `Content-Type`, and the answer is a public URL served through a CDN; a row in your database references the file by name. No bucket, no policy, no signed URL, no upload service of your own, and nothing to run without managing a server.

We built it as one HTML page with 120 lines of JavaScript, plus a `curl` script that measures everything the page cannot tell you: how big, how fast, who may upload, who may read, and what happens to the bytes when the row goes. Everything below was measured on September 24, 2026, including the deleted file the CDN kept serving.

**Get the working code:** the companion repo is at [github.com/templates-back4app/image-upload-starter](https://github.com/templates-back4app/image-upload-starter) — `web/`, `cloud/main.js`, `upload-check.sh`, `hook-check.sh` and `web-check.mjs`. Run the script against your backend and you get every table in this post for your own numbers.

## What is a file upload on a managed backend, and what does the URL look like?

A file upload is one HTTP request: `POST /files/<name>` with the file's bytes as the body, its MIME type in `Content-Type`, and the same two app headers every other call carries, plus the user's session token. The backend stores the bytes in an object store behind a CDN and answers `201` with two things, a `name` and a `url`.

![Architecture: the browser sends the image bytes with a session token to the backend's files endpoint, which stores the file in an object store behind a CDN and returns a public URL; the browser then creates a Photo row with an owner pointer and a file field that references the stored name; anyone with the URL gets the bytes from the CDN, row permissions or not](/blog/blog-assets/upload-and-serve-images-without-s3/architecture-diagram.svg)

This is the whole exchange, from `upload-check.sh`, with the App ID replaced:

```bash
# Stack: bash + curl | File: upload-check.sh (step 1)
curl -s -w '  %{http_code}\n' -H "X-Parse-Application-Id: $APP_ID" -H "X-Parse-JavaScript-Key: $JS_KEY" \
  -H "X-Parse-Session-Token: $SESSION_TOKEN" -H "Content-Type: image/png" \
  -X POST "https://parseapi.back4app.com/files/user.png" --data-binary @user.png
```

```text
{"url":"https://parsefiles.back4app.com//70294a8c55322f2b8fa059066317d0a3_user.png","name":"70294a8c55322f2b8fa059066317d0a3_user.png"}  201
```

Three things to read off that line. The `name` is your file name behind a 32-character hash, so two users uploading `avatar.png` never collide and nobody can guess a URL from a name. The `url` is on a different host from the API, `parsefiles.back4app.com`, and its first path segment is the App ID, which is a client key and ships in your page anyway.

And there is no expiry, no signature, no token in it: whoever has the URL has the file.

We fetched a 1 MB upload with no headers at all. `200`, `content-type: image/png`, `content-length: 999762`, `server: AmazonS3`, `via: … cloudfront.net`, `x-cache: Miss from cloudfront` on the first two requests from different edge nodes and `Hit from cloudfront` from the third on.

With an `Origin` header the answer added `access-control-allow-origin: *`, so `fetch()` and `<canvas>` work cross-origin, and a `Range: bytes=0-99` request got `206`. No `cache-control` header at all, which matters later.

{/* screenshot: database-files-view.jpg (if it exists) — Database → Files in the dashboard listing the stored objects by hashed name and size */}

## How do you upload an image from a web page in vanilla JavaScript?

With `fetch`, sending the `File` object itself as the body. The browser sets the length; you set `Content-Type` from `file.type`. There is no SDK in the page and no form encoding: the body is the bytes. Here is the helper and the upload function from `web/app.js`:

```javascript
// Stack: vanilla JavaScript (browser) | File: web/app.js
const BASE = window.BACKEND_URL ?? "https://parseapi.back4app.com";
const HEADERS = {
  "X-Parse-Application-Id": window.APP_ID,
  "X-Parse-JavaScript-Key": window.JS_KEY,      // a client key: identifies the app, not the user
  "X-Parse-Revocable-Session": "1",
};

// One helper for every call. `body` is a plain object (sent as JSON) or a File/Blob (sent as raw bytes with its type).
async function api(method, path, body) {
  const headers = { ...HEADERS };
  const token = session.get();                                    // sessionStorage; uploads without it get code 130
  if (token) headers["X-Parse-Session-Token"] = token;
  let payload;
  if (body instanceof Blob) { headers["Content-Type"] = body.type || "application/octet-stream"; payload = body; }
  else if (body) { headers["Content-Type"] = "application/json"; payload = JSON.stringify(body); }
  const r = await fetch(BASE + path, { method, headers, body: payload });
  const data = await r.json().catch(() => ({}));
  if (!r.ok) throw Object.assign(new Error(data.error ?? r.statusText), { code: data.code, status: r.status });
  return data;
}

// Step 1: the bytes. POST /files/<name> answers 201 {url, name}.
async function uploadFile(file) {
  const safeName = file.name.replace(/[^\w.]+/g, "-");            // the path segment; the served name gets a hash prefix
  return api("POST", `/files/${encodeURIComponent(safeName)}`, file);
}
```

The page has a signup/login form above the file input for a reason we did not plan: the first upload we tried, with only the App ID and the JavaScript key, was refused. `400`, code `130`, *File upload by public is disabled.* The same bytes with a session token: `201`. With the master key: `201`. So the page logs the user in, keeps the token in `sessionStorage`, and only then shows the input.

![The image upload starter page before any request: a Log in first form with username and password fields and the buttons Create account and Log in, above an empty status bar](/blog/blog-assets/upload-and-serve-images-without-s3/1-start.jpg)

The submit handler wraps the two calls in `performance.now()` and prints both timings in the status bar, which is where the browser numbers in this post come from:

```javascript
// Stack: vanilla JavaScript (browser) | File: web/app.js (upload handler)
$("#upload").addEventListener("submit", async (e) => {
  e.preventDefault();
  const file = e.target.file.files[0];
  const caption = e.target.caption.value.trim();
  if (!file) return;
  try {
    status(`uploading ${file.name} (${fmt(file.size)} bytes)…`);
    const t0 = performance.now();
    const stored = await uploadFile(file);                    // 201 {url, name}
    const t1 = performance.now();
    const photo = await createPhoto(stored, caption, file);   // 201 {objectId, createdAt}
    const t2 = performance.now();
    status(`uploaded ${fmt(file.size)} bytes in ${Math.round(t1 - t0)} ms, row ${photo.objectId} created in ${Math.round(t2 - t1)} ms\n${stored.url}`, "ok");
    e.target.reset(); await listPhotos();
  } catch (err) { status(`${err.code ?? err.status} ${err.message}`, "err"); }
});
```

We drove the page in headless Chrome with `web-check.mjs`, which signs up, puts a real file into the input through the DevTools protocol and clicks the button, so the timings are the page's own.

The page's numbers track `curl`'s within a second at every size: 1.21 s for 10 KB, 1.68 s for 100 KB, 6.84 s for 1 MB, 28.6 s for 5 MB and 54.8 s for 10 MB, with the row `POST` afterwards between 0.7 s and 2.2 s. The status bar prints both and the full URL.

![The image upload starter page after the uploads: the green status bar reads uploaded 32,281 bytes in 1351 ms, row IRNqQCosyk created in 1046 ms followed by the file URL with the App ID blurred, and the Photos list below shows the uploaded pictures with caption, uploader, size, time and the full file URL of each](/blog/blog-assets/upload-and-serve-images-without-s3/2-uploaded.jpg)

## How do you link the file to a Photo row and to its owner?

With a second `POST`, to `/classes/Photo`, whose `image` field is a **File** value: `{ "__type": "File", "name": "<the name from step 1>", "url": "<the url from step 1>" }`. The backend resolves the name to the URL whenever you read the row back; the `url` in the request is there because the backend needs it once a trigger runs on the class, as measured below.

`owner` is a pointer to the logged-in user, and the ACL says who may change the row:

```javascript
// Stack: vanilla JavaScript (browser) | File: web/app.js (the row)
// Step 2: the row. A File field references the stored file by name. Send the url from step 1 as well (see the finding
// in the deletion section: with a trigger on the class, name alone answered 502).
async function createPhoto({ name, url }, caption, file) {
  return api("POST", "/classes/Photo", {
    caption,
    image: { __type: "File", name, url },
    contentType: file.type,
    size: file.size,
    owner: { __type: "Pointer", className: "_User", objectId: me.objectId },
    ACL: { "*": { read: true }, [me.objectId]: { read: true, write: true } },
  });
}
```

Listing is one `GET /classes/Photo?order=-createdAt&include=owner`, and each result carries `image: { __type: "File", name, url }`. The page puts `url` in an `<img src>`; the CDN does the rest. We did not define the class or any column by hand: the first `POST` created `Photo` with `caption`, `image`, `contentType`, `size` and `owner`, typed from the values we sent.

{/* screenshot: database-photo-class.jpg — Database → Photo in the dashboard: the image column rendered as a file link, caption, owner pointer, size, contentType and the ACL column reading public read + owner write */}

One thing the screenshot shows that the code did not intend: the uploader reads `?` on photos by other users. `include=owner` expands the pointer only when the caller may read that user's row, and on this backend a user reading another user's row got `404 / 101`, session token or not. If the name matters, store it on the `Photo` row as a string at upload time.

Two decisions are baked into that ACL and both belong on the backend, not in the page. A `curl` command can send any `owner` and any `ACL` it likes with the same client key.

The repo's `cloud/main.js` has a `beforeSave("Photo")` that overwrites both from `request.user`, exactly as the `Note` class does in [the lockdown post](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to). The section on deletion below says what it does and what happened when we deployed it.

## How big can a file be, and how long does an upload take?

Bigger than we expected, and slower than the connection. We generated real PNGs of six sizes (random pixels, stored uncompressed, so the byte count is what we asked for) and sent each with one `POST` from `curl`, session token, `Content-Type: image/png`. Every one returned `201`:

| Size (bytes sent) | HTTP | Time, `curl` (median of 3, range) | Time, web page | Row `POST` after it |
|---|---|---|---|---|
| 10 KB (9,872) | 201 | 1.33 s (1.21–1.38) | 1.21 s | 1.10 s |
| 100 KB (99,649) | 201 | 2.33 s (2.06–3.08) | 1.68 s | 2.05 s |
| 1 MB (999,762) | 201 | 6.78 s (6.77–7.18) | 6.84 s | 0.72 s |
| 5 MB (4,995,285) | 201 | 29.5 s (27.8–31.5) | 28.6 s | 0.71 s |
| 10 MB (9,997,112) | 201 | 56.6 s (53.2–65.5) | 54.8 s | 2.17 s |
| 25 MB (24,998,119) | 201 | 130 s (one run) | – | – |
| 50 MB (50,008,266) | none | 200–329 s, then the connection closed, 3 of 3 | – | – |

How we measured: `curl -w '%{time_total} %{speed_upload}'` around each `POST`, from the first byte of the request to the last byte of the `201`, the median of three runs per size from 10 KB to 10 MB (started 13:08, 13:39 and 13:41 UTC) and one run for 25 MB (13:09 UTC), from a laptop to the USA East backend, free plan, on September 24, 2026.

The browser column is `performance.now()` around each `fetch` in `web/app.js`, one run per size at 13:31–13:33 UTC, read from the page's status bar by `web-check.mjs` in headless Chrome 154.

The absolute times are our uplink's. In the same minutes, the same 10 MB file went to two unrelated bandwidth-test endpoints at 0.49 MB/s and 0.38 MB/s, against 0.15 to 0.19 MB/s to the backend.

The 50 MB file is the one row with no status. Twice over HTTP/2 `curl` reported `Error in the HTTP2 framing layer` after 321 s and 329 s; once over HTTP/1.1 it got `100 Continue`, sent everything, and after 200 s got `Empty reply from server`. All 50,008,266 bytes left the laptop each time.

That is a limit between 25 and 50 MB or a request timeout of a few minutes; the backend did not say which, so neither do we.

So on this connection the upload path ran at about half the raw link, which is consistent with the backend receiving the whole body before writing it to the store and answering. Budget for roughly twice what a plain upload of the same bytes would take, and show progress.

## Who can read the file URL when the row is owner-only?

Everyone. That was the measurement we most wanted, and the answer is plain: the ACL guards the row, and the URL is not the row. We created a `Photo` whose ACL granted read and write to the owner alone, referencing the 1 MB file, and asked for it three ways:

| Request | Answer |
|---|---|
| anonymous (App ID + JavaScript key) → `GET /classes/Photo/2VU6ud9qUs` | `404 {"code":101,"error":"Object not found."}` |
| owner (session token) → `GET /classes/Photo/2VU6ud9qUs` | `200` |
| anyone, no headers at all → `GET <the file URL>` | `200 image/png 999762 bytes` |

The row hides. The file does not, because the request for the file never reaches the backend: it goes to the CDN, which knows nothing about `_User`, sessions or ACLs. Nothing in that path can be told to check anything.

That also decides what goes in the file. We uploaded a `.txt`, a `.svg`, a `.js` and a nameless blob with no `Content-Type` at all; all four came back `201`, and each URL served with exactly the type we sent. A PNG uploaded as `image/jpeg` was served as `image/jpeg`. Nothing sniffed the bytes.

The one refusal was `.html`: `130 File upload of extension html is disabled.`, which is the backend keeping a page with your App ID in its URL from hosting someone else's script.

## What happens to the file when you delete the row?

Nothing. That is the finding that shaped the rest of the repo. The owner deleted the `Photo` row, `200 {}`, and two seconds later the file URL still answered `200 image/png 999762 bytes`. The row and the file are two objects with two lifetimes, and the `File` field is a reference, not ownership.

So we tried to delete the file. `DELETE /files/<name>` with the session token: `403 {"error":"unauthorized: master key is required"}`. With the JavaScript key: the same `403`. With the master key: `200`. Then the URL again, two seconds later: `200`, the whole file. Ten seconds later: `200`.

The origin path on the API host, `GET /files//<name>`, said `404`; the public URL said `x-cache: Hit from cloudfront` and an `age` that kept climbing. We kept polling every two minutes. At 13:44 UTC, 32 minutes after the delete, the URL still returned `200` with `age: 1953`, and it was still doing so when we stopped watching.

| Step | Answer |
|---|---|
| owner → `DELETE /classes/Photo/<id>` | `200 {}` |
| `GET <file URL>` 2 s later | `200 image/png`, all bytes |
| session token → `DELETE /files/<name>` | `403 unauthorized: master key is required` |
| JavaScript key → `DELETE /files/<name>` | `403 unauthorized: master key is required` |
| master key → `DELETE /files/<name>` | `200` |
| `GET /files//<name>` on the API host | `404` |
| `GET <file URL>` (CDN), 2 s / 10 s / 32 min later | `200`, `x-cache: Hit from cloudfront`, `age` rising |
| `GET <file URL>?v=2` | `200`, same cached copy |

How we measured: `upload-check.sh` steps 7 and 8, one run at 13:12 UTC on September 24, 2026, then a poller fetching the deleted URL every two minutes and logging the status, `x-cache` and `age` headers, from one laptop through one CDN edge (the `x-amz-cf-pop` header read `MIA50`).

Two consequences. A client cannot delete a file, so "remove my photo" has to run on the backend. And "deleted" means the origin forgot the object while the edge keeps serving it, with no `cache-control` header to shorten that and no query string to bust it: the `?v=2` request was a hit on the same copy.

If the file was uploaded by mistake, count on it being reachable from that edge for a while after you delete it.

## How do you make a deleted row take its file with it?

With a `beforeDelete` hook on `Photo` in Cloud Code, which is the one place that may hold the master key. It runs on the backend for every delete, whichever client asked, reads the `image` field and destroys the file before the row goes:

```javascript
// Stack: Node.js 22.x | Parse Server 7.x (Cloud Code) | File: cloud/main.js
// Rule 1: a Photo belongs to whoever uploaded it, whatever the client claimed. Anyone may read the row; only the owner
// may change or delete it. Set here, on the backend, so a curl command cannot skip it.
Parse.Cloud.beforeSave("Photo", (request) => {
  const photo = request.object;
  if (!photo.isNew()) return;
  const owner = request.user;
  if (!owner) throw new Parse.Error(Parse.Error.SESSION_MISSING, "log in to upload a photo.");
  if (!photo.get("image")) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "a Photo needs an image.");
  photo.set("owner", owner);
  const acl = new Parse.ACL();
  acl.setPublicReadAccess(true);
  acl.setWriteAccess(owner, true);
  photo.setACL(acl);
});

// Rule 2: deleting the row deletes the file. DELETE /files/<name> needs the master key, which lives here and nowhere else.
Parse.Cloud.beforeDelete("Photo", async (request) => {
  const file = request.object.get("image");
  if (!file) return;
  try {
    await file.destroy({ useMasterKey: true });
  } catch (err) {
    // A file that is already gone must not block the row delete; anything else should.
    if (err.code !== Parse.Error.FILE_DELETE_ERROR && err.code !== Parse.Error.OBJECT_NOT_FOUND) throw err;
  }
});
```

`file.destroy({ useMasterKey: true })` is the same `DELETE /files/<name>` we sent from the terminal, issued from inside the backend with the key the client never had. The `beforeSave` above it is the other half: `owner` and the ACL come from `request.user`, so the page's own values are overwritten and a `curl` command cannot claim someone else's account.

Cloud Code deploys from the dashboard (Cloud Code → main.js → Deploy, twice on a fresh backend: the first deploy ships nothing, a behaviour we have seen on seven backends before this one). The repo's `hook-check.sh` is the proof: two users, an upload, a row created with the wrong `owner` and an open ACL on purpose, then the owner's delete and a `GET` of the file's origin path, which must be `404`.

This is the run, at 14:38 UTC on September 24, 2026, with the file deployed. `ana` uploaded, then created a row that claimed `owner` = `bob` and a public-write ACL. The row came back with `owner` = `ana` and `ACL {"*":{"read":true},"<ana>":{"write":true}}`: the client's claim was overwritten. Then she deleted it, holding nothing but her session token:

| Step | Answer |
|---|---|
| anonymous → `POST /classes/Photo` with an image | `400 {"code":206,"error":"log in to upload a photo."}` |
| ana → `POST /classes/Photo`, body claims `owner: bob`, `ACL: {"*": read+write}` | `201`, stored `owner: ana`, `ACL {"*":{"read":true},"ana":{"write":true}}` |
| bob → `PUT` / `DELETE` that row | `404 / 101 Object not found.` / `Object not found for delete.` |
| ana → `POST /classes/Photo` without an image | `400 / 142 a Photo needs an image.` |
| before the delete: origin path / CDN URL | `200` / `200 image/png` |
| ana → `DELETE /classes/Photo/sSo3LhjDmY` | `200 {}` |
| 2 s and 12 s later: origin path `GET /files//<name>` | `404` |
| 2 s and 12 s later: CDN URL | `403`, `x-cache: Error from cloudfront` |

How we measured: `hook-check.sh`, one run at 14:38:05–14:38:39 UTC on September 24, 2026, two fresh users, `curl` from a laptop; then one master-key delete at 14:39 of a row the page had created before the hook existed, which also returned `200` and left the origin path at `404`.

The CDN line differs from the earlier finding, and the difference is instructive. This file had been fetched once through the CDN before the delete (`x-cache: Miss`), and after the delete the edge answered `403` at once.

The 1 MB file from the morning had been fetched several times (`Hit`), and its cached copy kept serving for 32 minutes after its delete. What the edge already holds, it keeps; what it has not stored, it goes back to the origin for.

The same request with the `url` from the upload response answered `400 / 206` or `201` as it should, and so did a body with no `image` at all.

Before the trigger existed, `name` alone had worked all morning: the page and the script created every row that way. Send both `name` and `url`, exactly as the upload answered; the page and both scripts now do.

The finding explains an ugly stretch we have to own. For about ten minutes after the Cloud Code deploy (14:11–14:24 UTC) the app restarted repeatedly and every Photo write answered `502`, while the dashboard's System log showed the restarts and the Error log showed nothing but our own `142` entries.

Our first proof script sent `name` alone, thirty times, and each attempt restarted the app; whether the deploy started it or our retries did, we cannot say.

## Which upload errors will you hit, and what do their bodies say?

Every refusal we met on September 24, 2026 came as an HTTP status plus a JSON body; branch on the `code`, and on the `error` text for the two that share `130`:

| What you did | HTTP | `code` | `error` |
|---|---|---|---|
| `POST /files/<name>` with only the App ID and a client key | 400 | 130 | `File upload by public is disabled.` |
| `POST /files/probe.html` (session token) | 400 | 130 | `File upload of extension html is disabled.` |
| `DELETE /files/<name>` with a session token or client key | 403 | – | `unauthorized: master key is required` |
| `GET /classes/Photo/<id>` for a row your ACL cannot read | 404 | 101 | `Object not found.` |
| `GET /files//<name>` on the API host after the file was deleted | 404 | – | `Not Found` (plain text) |
| `GET /files` with the master key (there is no listing endpoint) | 404 | – | `{"message":"Not Found","error":{}}` |
| `POST /classes/Photo` with `image: {"__type":"File","name":…}` and no `url`, once a trigger exists on the class | 502 | – | `Bad Gateway` (plain text), then `/health` 502 for a second |
| `POST /classes/Photo` without a session, hook deployed | 400 | 206 | `log in to upload a photo.` |
| `POST /classes/Photo` without an `image`, hook deployed | 400 | 142 | `a Photo needs an image.` |

**`130 File upload by public is disabled.`** is the one your form will hit first. The fix is a session, not a key: sign the user up or log them in, send `X-Parse-Session-Token` with the `POST`, and keep the master key out of the page. The starter shows the file input only after `GET /users/me` has accepted the token.

**`130 File upload of extension html is disabled.`** is by name, not by type: the same bytes as `probe.txt` with `Content-Type: text/html` were accepted. Rename or refuse in the page, and do not expect the backend to reject a script by content: `probe.js` went through as `text/javascript`.

**`502 Bad Gateway`** on a Photo save, with the app briefly unhealthy after it, is the File-without-`url` case from the deletion section. Send `{"__type":"File","name","url"}` exactly as the upload answered, and never retry a `502` in a loop: each one restarts the app.

**A `201` with the wrong `Content-Type`** is the error you will not see. The URL serves whatever type you sent, so read `file.type` from the `File` object and refuse anything that is not `image/*` in the page before you upload, which is what the `accept="image/*"` on the input does for a human and a two-line check does for a script.

## When do you want object storage after all?

When the file is the product, or the URL must expire. Three cases where a bucket you administer, or a dedicated file service, beats the files endpoint:

- **Very large files.** Our 25 MB upload took 130 s in one request on a slow link, with no resume, and 50 MB never got an answer. Video, raw photos and exports want multipart, resumable uploads with progress per part, which a bucket's API and a browser client library give you and a single `POST` does not.
- **Signed, expiring URLs.** A file here is public until deleted, and cached beyond that. Paid downloads, medical images, contracts, anything where the URL must stop working at a time you choose, need signed URLs with an expiry, which means an object store and a server or function that signs.
- **Transforms on the fly.** Thumbnails, resizing, format conversion, watermarks: an image CDN does that from a URL parameter. Here you upload the sizes you need (the page can resize on a `<canvas>` before the `POST`) and reference each from the row.

For avatars, listing photos, attachments and anything a user is happy to have on a public URL, the two calls above are the whole feature, and the part you own is 32 lines of Cloud Code.

## What would you add to the image upload starter next?

- **Resize in the browser.** Draw the picture on a `<canvas>` at 1600 px, `toBlob("image/webp", 0.85)`, and upload that: our 5 MB phone photo becomes a 300 KB upload, and the time table above shrinks with it.
- **Progress.** `fetch` cannot report upload progress; `XMLHttpRequest` can. Swap `uploadFile` for an `xhr.upload.onprogress` version and show a bar, which the 57 s for 10 MB earns.
- **A cleanup job.** Files uploaded whose row creation failed (the second `POST` can fail after the first succeeded) are orphans nobody references. A scheduled Cloud Job that lists `Photo` rows and deletes unreferenced files with the master key keeps the 1 GB honest.
- **Private files.** A `Document` class with an owner-only ACL and a Cloud Function `download(id)` that checks `request.user` and returns the bytes with the master key: the pattern from the counter-case, on this backend.
- **Users first, permissions next.** The page needs accounts because uploads need a session: that is [the auth post](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server). Who may read and write the `Photo` rows, with a probe you can rerun, is [the lockdown post](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to).
