Upload a file from a React form without exposing your API key

3 September 2026

Every upload API gives you a key that can write to your account. The first thing people do is put it in the React app, because that is where the file picker is. Then the key is in the bundle, and anyone who opens the network tab can upload whatever they like to your storage, on your bill.

The fix is not complicated, and it is the same shape for Filemon, S3 or anything else: the browser never talks to the storage service. It talks to your server, which holds the key.

The three parts

  1. A form in the browser that posts the file to your own backend.
  2. A route on your backend that forwards it, with the key attached.
  3. The URL that comes back, saved wherever you keep it.

The form

No library, no SDK. A file input and a FormData:

export default function AvatarForm({ onDone }) {
  const send = async (e) => {
    e.preventDefault();
    const body = new FormData(e.target);
    const res = await fetch("/api/avatar", { method: "POST", body });
    const { url } = await res.json();
    onDone(url);
  };

  return (
    <form onSubmit={send}>
      <input type="file" name="avatar" accept="image/*" required />
      <button>Upload</button>
    </form>
  );
}

The accept attribute is a hint for the file picker, not a rule. Keep it, and do not trust it.

The route

Your backend receives the file and passes it on. The key lives here, in an environment variable, and never leaves:

app.post("/api/avatar", async (req, res) => {
  const user = await currentUser(req); // your session, your rules
  const body = new FormData();
  body.append("file", req.file);

  const upload = await fetch("https://filemon.io/api/m1o900k8", {
    method: "POST",
    body,
    headers: { Authorization: `Bearer ${process.env.FILEMON_KEY}` },
  });

  const { url } = await upload.json();
  await db.users.update(user.id, { avatar: url });
  res.json({ url });
});

m1o900k8 is a template id: it decides that this upload is an avatar, so it comes back 400x400 and converted, whatever the user picked. The response is the id and its permanent URL.

Why the middle step is worth it

It is tempting to skip your own server and hand the browser a short-lived credential instead. That works, and it is a lot more machinery: an endpoint that mints the credential, an expiry, a scope so it cannot be reused for something else, and a way to revoke it.

The forwarding route gives you something the direct upload cannot, and it is the thing you will want by week two: the upload happens inside your own permission check. You know which user it belongs to, you can refuse it, and you can write the resulting URL into your database in the same request. No webhook, no reconciliation, no orphaned files from uploads that never got claimed.

The cost is that the bytes pass through your server. For avatars and product photos, on a machine that is already handling requests, that is nothing worth optimising.

What you store

Store the id, not just the URL. The id is sixteen characters and every URL for that file is derived from it: the file itself, the untouched original, and the thumbnail. If you store the whole URL you have written today's host into your database, which you will regret the first time it changes.

Try it on your own files

Filemon resizes and converts on upload, so the URL you get back is already the finished file. The free plan needs no card.

Create an account · Read the documentation