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
3
Save the public URL
The organization logo
Identical, with two differences: it needsorganization: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.