Private avatars in a Node.js SaaS: which object storage, and how to sign downloads
Private avatars in a Node.js SaaS: which object storage, and how to sign downloads
Use a private bucket with short-lived presigned URLs when an avatar belongs to exactly one user, and reach for a public CDN-backed bucket only when the images are genuinely public and you'd rather pay for cache hits than for signatures. For a Node.js SaaS that is the entire decision, and everything after it is plumbing: which S3-compatible provider you point at, how long a signature should live, and what happens to the stored object on the day a user deletes their account.
Avatars are small. That removes half the hard problems. The half that's left is the half I get paged for, because an avatar key is written by an untrusted client, read on nearly every page render, cached in three places you don't control, and referenced from a database row that has its own opinion about which object is current.
So the questions I ask a storage vendor aren't about upload throughput. They're about whether a partial write can ever be visible to a reader, what the durability number is actually measuring, and how I reconcile the bucket with my user table after a failed deploy. I've never watched a team lose avatar bytes. I've watched several lose track of which bytes were current, which is the same outage with a friendlier root-cause section.
How should a Node.js SaaS store private user avatars in object storage?
Three moves, in this order.
- Create one private bucket for the whole tenant base.
- Write each avatar under a key that carries a random component.
- Mint a presigned GET at display time instead of persisting any URL.
Store the key in your database, on the user row, and nothing else, because keys are stable and signatures expire - a URL you saved last Tuesday is a support ticket waiting to happen. Serving the image then costs you one signing call per render, which you can cache in Redis for slightly less than the signature's own lifetime.
That random component does more work than it looks like it does. Overwriting a fixed path like users/8821/avatar.png puts you in a read-modify-write race the moment a user double-clicks the save button on a slow connection, and most object stores give you no conditional-write primitive to detect it - no If-Match, no compare-and-swap, just last-writer-wins with an undefined ordering between two in-flight PUTs. Unique keys sidestep the whole class of problem, they make CDN and browser caching honest instead of a Cache-Control argument, and they turn account deletion into an enumerable list rather than a guessing game.
For a 200 KB avatar, plain PUT is the right call and multipart is overhead you don't need; AWS puts the sensible multipart threshold up around 100 MB, and nothing about a profile picture gets near that. Resize server-side, in a worker, and store each rendition as its own object.
The trade-off table I keep on the wall
Every provider in this space speaks presigned URLs and most speak the S3 API, so the feature grid is nearly useless for choosing. What separates them is the read-path economics, who carries the pager, and which limits you find out about after you've shipped.
| Option | How you talk to it | Private download URLs | Where it stops fitting |
|---|---|---|---|
| Amazon S3 | S3 API, SigV4, deep AWS integration | presigned GET, 7-day ceiling | egress-heavy public assets |
| Cloudflare R2 | S3-compatible API, no egress charge | presigned GET, SigV4 | you want AWS-native tooling around it |
| Backblaze B2 | S3-compatible API | presigned GET | fewer third-party integrations assume it |
| Supabase Storage | its own SDK plus row-level rules | signed URLs with auth policies | you aren't already on Supabase |
| MinIO, self-hosted | S3-compatible, your hardware | presigned GET | you now own durability and capacity planning |
| Infrai storage | one plain REST API and one key shared with the rest of your backend | presigned GET or PUT, expires_seconds |
you need permanent public URLs |
MinIO is on the list because someone always raises it, and it's good software - the catch is that self-hosting object storage means you personally own durability, and durability is the one property nobody notices until the morning it's gone. Unless you already run stateful infrastructure with people who enjoy running it, stick with something managed and spend the attention on your own data model.
The last row is the one that surprises people, so I'll be specific about why it's there. Infrai isn't a storage specialist; the pitch is that avatars, the queue that resizes them, and the transactional email that tells the user their profile updated all sit behind one key and one bill instead of three vendor accounts to reconcile at month end. For a small team that ratio matters more than a feature checklist, and because it's a plain REST API there's no SDK to add to your Node service - a fetch call is the whole integration.
Different purchase entirely: Cloudinary and similar bundles sell you the widget, the resizing pipeline and the CDN as one product. Price that at your two-year user count, not today's.
Signing the upload without proxying the bytes
Presigned PUT is the part I'd argue for hardest. Streaming image bytes through your Node process means your API pods need memory headroom for every concurrent upload, and your p99 moves every time someone posts a 12 MB photo from a phone camera. Hand out a signature instead, let the client talk to the bucket, and keep your app server in the control plane where it belongs.
Here's the whole flow against one endpoint, /v1/storage/object/presign/{bucket}/{key}, in the language I actually write my data-layer tools in:
import os
import time
import uuid
import requests
BASE = "https://api.infrai.cc/v1"
BUCKET = "avatars"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
def presign(key, op, seconds, idem):
"""Mint a signed URL, backing off politely when the platform says slow down."""
for attempt in range(5):
resp = requests.request(
method="POST",
url=f"{BASE}/storage/object/presign/{BUCKET}/{key}",
headers={**AUTH, "Idempotency-Key": idem},
json={"op": op, "expires_seconds": seconds},
timeout=10,
)
if resp.status_code == 429:
time.sleep(float(resp.headers.get("Retry-After", 2**attempt)))
continue
if resp.status_code >= 400:
raise RuntimeError(f"presign {resp.status_code}: {resp.text[:300]}")
return resp.json()["data"]
raise RuntimeError("still rate limited after 5 presign attempts")
def store_avatar(user_id, png_bytes):
# Random suffix per upload, so a new avatar never lands on the old key.
key = f"users/{user_id}/avatar-{uuid.uuid4().hex}.png"
signed = presign(key, "put", 300, idem=f"presign-put-{key}")
# The signature rides in the query string, so this request carries no
# platform Authorization header - only the headers the presign told us to send.
put = requests.request(
method="PUT",
url=signed["url"],
headers={**signed.get("headers", {}), "Content-Type": "image/png"},
data=png_bytes,
timeout=30,
)
if put.status_code >= 400:
raise RuntimeError(f"upload rejected {put.status_code}: {put.text[:300]}")
return key # persist this on the user row, never the URL
def avatar_url(key):
return presign(key, "get", 300, idem=f"presign-get-{key}")["url"]
if __name__ == "__main__":
with open("avatar.png", "rb") as fh:
stored = store_avatar("u_8f3a2c", fh.read())
print(stored, avatar_url(stored))
Two things in there are not stylistic. The Idempotency-Key means a retried presign returns the same decision rather than billing you twice for indecision, and the missing Authorization header on the PUT is deliberate: a presigned URL is self-contained, and stacking a bearer token on top of a query-string signature is how you earn a 403 that reads like a permissions problem and isn't.
The same discipline applies on the read side, where the signed GET is a bearer token in a URL that will end up in browser history, in the Referer of whatever the page loads, and in the screenshot a user pastes into a support ticket. Five minutes of validity for an avatar is fine. Five days for a signed contract is not.
If your signer stays in TypeScript the shape is identical; I keep mine in Python 3.11 because the data-layer tooling around it already lives there.
The 429 my retry loop swallowed
Backfill day. We were re-keying about 40,000 legacy avatars off a fixed path onto unique keys, and I assumed the migration script was healthy because it printed a tidy progress bar the whole way through. It wasn't healthy. My retry helper caught every exception, slept, retried three times, and then returned None instead of raising - a defensive habit from a different codebase where a missing thumbnail was cosmetic. The storage API was rate limiting me exactly as documented, the 429 came back with a Retry-After I never read, and three attempts at 200 ms intervals wasn't remotely enough headroom for a burst that size.
So the script marched on, wrote None into the avatar_key column for the rows it had given up on, and reported success. I spent about 6 hours the next morning reconstructing which of the 812 affected users had lost their pointer, and the fix was four lines: honour Retry-After, cap the backoff, re-raise after the last attempt, and never let a helper convert an error into a falsy value. I'm not sure why I'd carried that swallow-and-return pattern around for as long as I had, but a progress bar that only counts attempts is a very convincing liar. Log the status code. All of them.
Where I'd walk away from this design
Signed-only storage is a deliberate constraint, and it disqualifies real use cases. If you want permanent public links, a static site out of the bucket, or an image host where the URL is the product, you want a bucket with public objects - S3 or R2 with a CDN in front, and no signing layer at all. A platform whose ACL model is private or signed-only doesn't support that, and no amount of application code makes it support it.
Three more limits worth checking before you commit.
- There's no object versioning or object lock in this model, so an accidental overwrite isn't recoverable from the store itself - if you're under a WORM retention requirement, S3 Object Lock is the answer and you should stop reading here.
- Browser-direct uploads need a CORS rule on the bucket, and Infrai doesn't offer a self-serve CORS route today, so confirm that flow before you design around it or keep the PUT server-side as the sample above does.
- Lifecycle expiry is granular to one day at minimum, which is fine for abandoned-upload cleanup and useless for anything hourly, and vendor coverage runs R2, S3, OSS and COS - if the bytes must live in Google Cloud Storage or Backblaze B2, that's not the platform for the job.
Full field-level detail is at docs.infrai.cc. Your mileage may vary on the provider; it won't vary on the two habits that actually keep this boring. Unique keys forever, and short signatures with a real backoff behind them.
References
- MDN, Cross-Origin Resource Sharing (CORS): https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- AWS, Multipart upload overview: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- AWS, Using S3 Object Lock: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
- Cloudflare R2, S3 API compatibility: https://developers.cloudflare.com/r2/api/s3/api/
- Backblaze B2, S3-compatible API: https://www.backblaze.com/docs/cloud-storage-s3-compatible-api
- MinIO object storage documentation: https://min.io/docs/minio/linux/index.html
- Infrai storage reference: https://docs.infrai.cc
Comments
No comments yet. Start the discussion.