The Problem Presigned URLs Solve
In one line
A presigned URL expresses "this person can do only this action on this object for this long" as a single signed URL. The bytes no longer have to pass through the app server.
Why it was needed
The simplest way to let a user download a file is this. The application checks permissions, reads the object from storage and streams it out as the response body.
The problem with this method shows up at scale. If 100 videos of 500MB are downloaded at the same time, the app server relays 50GB. Workers are tied up for that time, bandwidth is trapped by the app server's specs, and if memory buffering is done wrong, you get OOM. An app server should handle logic, not become a pipe that carries bytes.
A presigned URL divides the roles. The app decides permissions and signs a URL and returns it. The actual byte transfer happens directly between the client and the storage. The app only has to make a URL a few bytes long.
How it works
The URL carries constraints along with the signature. Which action (GET/PUT), which object, until when (X-Amz-Expires), with which credentials (X-Amz-Credential), and which headers were included in the signature (X-Amz-SignedHeaders). The storage verifies this signature and allows the request. The key point is that the signature is made from the credentials, but the secret key itself is not in the URL.
It is also used for uploads. If you give a presigned PUT URL, the client uploads directly to the storage. What to watch out for here is that you must limit what and how much the client uploads. With a presigned POST policy, you can tie the Content-Length range and the Content-Type to the signature.
The shorter the expiry, the better. A few minutes for downloads and a few tens of minutes for uploads is usually enough. This is because a URL can leak through logs or referrer headers. Conversely, if it is too short, a user on a slow line cannot finish the upload.
A policy is a different layer. If a presigned URL is temporary delegation, a policy is standing permission. You write in IAM-style JSON which principal can perform which action on which resource. A common mistake here is setting Resource to the whole bucket. If you narrow it by prefix, even if one service is breached, the damage is confined within that prefix.
What it looks like in the field
Opening a bucket to anonymous reads is the starting point of most data leak incidents. It is common to open it with "it only has images, so it's fine" and later have backups or user uploads mixed in under it. If anonymous public access is really needed, open only a specific prefix such as public/, not the whole bucket, and enforce in code a rule that only things that may be public go into that prefix.
And vulnerabilities in which privileges escalate through a session policy bypass, like MinIO's CVE-2025-62506, do actually appear. Writing policies narrowly is also a defense against implementation bugs.
What trips you up when uploading directly from a browser
When you first attach a presigned URL, you usually get blocked at the same spots. We go through them in order.
CORS. For a browser to send a PUT to storage at a different origin, it must first send a preflight request with OPTIONS, and the storage must answer with the allowed headers. This setting has to be put on the bucket separately, and without it, the signature is fine but only the browser console shows a CORS error. Because testing from the server with curl works well, it is easy to look for the cause in the wrong place.
The signed headers must be sent exactly as they are. If you included Content-Type in the signature, the client must send exactly the same value, and if even one differs, you get SignatureDoesNotMatch. Browsers or HTTP libraries sometimes add headers on their own or change capitalization, so it is safer to reduce the headers you put in the signature to only those that are truly necessary.
The clock. The signature contains the issue time, and the storage verifies with its own clock. If the two clocks are off by more than a few minutes, a URL you just made is rejected as "already expired." You actually run into this in containers without NTP or right after a laptop wakes from sleep.
You also have to decide in advance what happens after the upload finishes. Since the client uploaded directly to storage, the application cannot know on its own that the upload has finished. A method where the client tells you "it's all uploaded" simply breaks if that request does not arrive. So in practice, two things are used together. You receive object creation through the storage's event notifications and process it, and at the same time periodically sweep and clean up items that are not finalized after a certain time.
Large files are not uploaded in one go. Multipart upload splits a file into pieces, uploads them in parallel and combines them at the end, so even if it is cut off midway, you only have to upload that piece again. In exchange, pieces for which you never called the combine step stay as they are and only cost money. They do not appear in the visible object list, so it is common to discover them months later when the capacity does not add up. The standard practice is to put in, when you create the bucket, a lifecycle rule that automatically deletes incomplete uploads after a few days.
What we do in the next lab
After confirming that anonymous access is denied, you create and use a presigned GET and PUT and verify expiry. Then, in a separate lab, you create a user and write and attach a read-only policy and a prefix-limited policy.