Skip to main content
Two things can be uploaded: a user’s avatar and an organization’s logo. Both work the same way, and neither sends bytes through the API.
Requires S3-compatible storage (S3, R2, MinIO). Without it both routes answer 501 uploads_unavailable and GET /v1/session reports uploads_available: false.

The flow

1

Ask for a signed URL

2

PUT the file straight to the bucket

Send exactly the headers that came back — they are part of the signature.
3

Save the public URL

Saving is a separate call on purpose. The upload can fail halfway, and a profile pointing at a half-written object would be worse than one still pointing at the old image. Identical, with two differences: it needs organization:update, and the URL is saved with PATCH /v1/organization.

What is allowed

An allowlist, not a blocklist — deliberately. These files are served back from a public host, and text/html among them would be a stored-XSS vector on that host. Content type and size are part of the signature, so the bucket enforces them. The check on the API side is there to give you a readable error, not to be the enforcement.

Replacing an image

Every upload gets a random object key, so replacing an image never overwrites the old object. A URL cached in a CDN or an email cannot start serving somebody else’s picture. The old object is left behind. Pruning is a housekeeping job, not part of the request.

Doing it from the browser

The panel does exactly the three steps above from client code. The presigned URL is short-lived and scoped to one object, one content type and one size, so handing it to the browser gives away nothing reusable.
In the panel these go through the Next app’s /api/* proxy, which attaches the session token from the httpOnly cookie. The PUT goes straight to the bucket and carries no credential of ours at all.