A stranger hands you bytes of their choosing, you keep them indefinitely, and then you serve them to other people. Each of those three steps needs its own control, and none of them needs a server.
Every app I build lets somebody upload something. A caregiver attaches a document, a contractor posts photos of finished stonework, a user sets a profile picture. For a long time I treated the upload as the easy part of those features: a file input, a call to the storage SDK, a URL saved on a document. It is not the easy part. An upload is the one place where a stranger hands you bytes of their choosing, you keep them indefinitely, and then you serve them back to other people. That is three problems wearing one feature, and each one has a different owner in the code.
The way I think about it now: an upload is untrusted input that you then publish. Everything below follows from taking both halves of that sentence seriously.
A photograph straight off a phone is not just a picture. It is several megabytes of pixels at a resolution nobody will ever view, plus a metadata block that usually includes the camera model, the time it was taken and, unless the user has turned it off, the GPS coordinates of where they were standing. On a contractor marketplace that is frequently a customer's back garden. On a care product it can be the inside of someone's home.
The fix I use is to never upload the original. Before anything leaves the device, the browser decodes the image, draws it onto a canvas at the largest size the product actually displays, and re-encodes it. That one step does three jobs at once. It shrinks the file, often by an order of magnitude, which is the storage bill. It discards the metadata, because a canvas holds pixels and nothing else, which is the privacy problem. And it normalises the format, so what reaches the server is always an image encoder's output rather than whatever the user's file claimed to be.
The order matters. If the resize happens on the server, the original has already been transmitted, stored at least transiently, and possibly logged. Doing it on the device means the location data never leaves the phone, and it costs me nothing, because the work runs on hardware the user already owns. That is also why I do not run a function that generates thumbnails after the fact: it would be a server that has to exist, triggered per upload, reading the large file I should never have accepted.
Video I do not host at all. A link to where the user already keeps it is cheaper, avoids a transcoding problem I have no reason to own, and leaves the storage line flat.
Browser-side resizing is a courtesy to the user and a saving for me. It is not a control. Anyone can skip my JavaScript and talk to the storage API directly with their own token, so everything the client promises has to be re-checked where the client cannot reach.
On Firebase that place is the Storage security rules, and they can check more than people
expect. A rule for a profile image in my projects says, in order: the request is authenticated; the
path contains the requester's own user ID, so nobody can write into anyone else's folder; the file
is under a size cap chosen from what the resized output actually weighs, with headroom but not
much; and the declared content type starts with image/. Anything that fails any clause
is refused before a byte is stored.
The size cap is the clause I would regret skipping. It is what turns a storage bucket from an open-ended liability into a bounded one, because the worst a hostile user can do is fill their own folder with files that cannot individually be large. Pair it with a rule that each user writes only under their own path and the arithmetic of abuse becomes something you can actually reason about.
The content type check deserves a sentence of honesty: it checks what the uploader declared, not what the bytes are. A client can label anything as an image. Where it matters, and for documents it does, the file's leading bytes should be checked against the signature of the accepted formats before the upload is attempted, with the rule as the backstop for anyone who bypasses that check. The rule cannot read the bytes; it can at least ensure the declared type is one I will later serve safely, which brings me to the part most people forget.
The most dangerous moment in an upload's life is not when it arrives. It is when someone else's
browser fetches it. If a user can upload a file that your origin serves back as
text/html, they have written a page on your domain, with your cookies and your
reputation, and any script in it runs as you. The same is true of SVG, which is an image format that
can contain script.
So the rules I apply when serving are deliberately boring. User uploads are never served from the
same origin as the application. The storage bucket is private, and files are reached through
access-controlled download URLs rather than a public listing. Anything that is not a raster image is
served with a Content-Disposition that makes the browser download it rather than render
it, and with a content type fixed to what I accepted rather than echoed from the upload. SVG is not
on the accepted list at all; I have never had a product reason to take one from a user, and the
risk is not worth a feature nobody asked for.
There is a quieter version of the same mistake in file names. A name supplied by the user ends up in a path, a URL, a download header and sometimes a page. I never use it as the stored name. The stored object gets a generated name under the owner's folder, and the original name, if the product needs it at all, is saved as data and escaped wherever it is displayed.
Storage is the one metered resource in these projects that only ever goes up, because nothing deletes itself. A read is billed once and is over. A stored file is billed every month for as long as it exists, including the months after its owner stopped using the product.
That is why every upload path I write now starts from its deletion path rather than ending with it. Before a feature ships I want an answer to three questions. When the user replaces this file, does the old one get deleted, or merely unlinked? When the user deletes their account, which folder goes with it, and does the deletion run in the same flow the user triggered rather than in a job I have to remember to schedule? And for files attached to something with a natural end, such as a completed job or a closed record, how long do we keep them and what removes them?
Keeping the owner's user ID in the storage path is what makes the second question cheap. Account deletion becomes "delete everything under this prefix", which is one operation with an obvious scope, instead of a hunt through documents for URLs that might point at files. The path layout is a deletion strategy decided at upload time, and it is very hard to change once files exist under the old one.
The replacement case is the one that leaks quietly. A profile picture changed ten times is ten files if the upload code only writes the new URL to the profile. Nobody sees the other nine, and nobody is ever charged a noticeable amount for them, until the same pattern has run across every user for a couple of years. Overwriting at a fixed path per user, or deleting the previous object after the new one is confirmed, turns that into one file.
Written out, the design every upload feature in my projects now has to meet is short:
None of those steps needs a server, and none of them costs anything to run. That is not a coincidence. The design that keeps the bill flat is the same design that keeps the location of a customer's garden off the internet: accept less, keep less, and serve what you keep as if the person who uploaded it were trying to hurt the person who downloads it. Occasionally they are.
Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.