A presigned URL is a bearer capability. Anyone holding the string can do what it permits, with no identity check, until it expires. That is the entire security model, and it is why the mistakes are all variations of one thing: handing out more capability than intended, for longer than intended.
This post is the specific ways that happens on both the upload and download side. For whoever lets users put files into object storage or take them out.
The upload side, where the interesting bugs are
Presigned uploads let a browser send a file straight to storage without it passing through your server. Good for cost and latency, and it moves a decision your server used to make to a place with no logic in it.
The client chooses the key. If your endpoint takes a filename from the client and signs a URL for that path, the client controls where the object lands.
POST /api/upload-url {"filename": "../../avatars/other-user-id.png"}
Now they can write over another user's object, or drop a file somewhere your application treats as trusted. Generate the key server-side, from a random identifier plus the authenticated user's namespace, and never accept a path from the client.
The signature does not constrain content type or size. A URL signed without conditions accepts anything of any size. If your application later serves that object, an attacker uploads HTML or SVG and you have stored cross-site scripting served from your own domain.
Bind the conditions into the signature. With S3 this means a POST policy with explicit constraints rather than a bare PUT presign:
s3.generate_presigned_post(
Bucket=bucket,
Key=f"uploads/{user_id}/{uuid4()}.jpg", # server-chosen
Fields={"Content-Type": "image/jpeg"},
Conditions=[
{"Content-Type": "image/jpeg"}, # enforced, not advisory
["content-length-range", 1, 5_000_000],
],
ExpiresIn=300,
)
The Conditions list is what makes it a control. Fields without matching conditions are suggestions.
Nothing validates the content afterwards. A declared content type is a claim by the uploader. If the object will be served or processed, check the actual bytes server-side after upload, and store the result of that check rather than the client's assertion.
The bucket serves what it stores. If uploads land in a bucket with public read and no content-disposition override, you are hosting arbitrary user-controlled content on a domain. Serve user content from a separate domain, force Content-Disposition: attachment for anything not an image, and never serve it from the origin that holds your session cookies.
The download side
Expiry set in days. A seven-day URL sits in browser history, in the referrer header if the page links out, in corporate proxy logs, and in any email it was pasted into. Minutes is usually right. If a user needs longer access, they can request a new link, which is one more API call and a much smaller window.
The URL is not bound to anyone. A link generated for user A works for whoever receives it. For sensitive documents, that is the whole exposure: forwarding the email forwards the access. Where it matters, proxy the download through your application so you can check the session, and accept the bandwidth cost.
URLs in logs. Presigned URLs carry the signature in the query string, so every access log, APM trace and error report that records a full URL records a working credential. Strip query strings from logged URLs, and be aware your vendors may not.
Enumerable keys. Sequential or predictable object keys mean a valid signature for one object suggests where others live. Use random identifiers.
Check yours
# Does the signed URL actually constrain the content type?
curl -s -o /dev/null -w "%{http_code}\n" -X PUT "$PRESIGNED_PUT_URL" \
-H 'Content-Type: text/html' --data '<script>alert(1)</script>'
# 200 means the type was never enforced
# Can the client steer the key?
curl -s -X POST https://api.example.com/upload-url \
-H "Authorization: Bearer $TOKEN" \
-d '{"filename":"../../other-user/avatar.png"}' | jq .
# How long does a download URL live?
# Decode X-Amz-Expires or the equivalent from the query string.
# Is the bucket itself readable without a signature?
curl -s -o /dev/null -w "%{http_code}\n" "https://bucket.s3.amazonaws.com/uploads/"
The first one is the highest-yield test. A presigned PUT that accepts text/html when the application expects a JPEG is common and it is a stored cross-site scripting primitive if that object is ever served inline.
The bucket policy itself
Worth reading once, properly, because it is the backstop when the signing logic is wrong:
- Public access blocked at the account level unless there is a specific reason.
- No wildcard principal in the bucket policy.
- Encryption at rest enabled, and enforced by policy rather than set on individual objects.
- Versioning on, so an overwrite is recoverable.
- Access logging on, so you can answer what was read during an investigation.
- A lifecycle rule deleting temporary upload prefixes, so abandoned objects do not accumulate indefinitely.
That last one matters more than it sounds: staging prefixes full of user uploads nobody ever processed are a data store nobody governs.
The concession
Proxying every download through your application gives you authorisation, logging and revocation, and it costs bandwidth, latency and complexity at scale. Presigned URLs exist because that cost is real, and for public or low-sensitivity content, direct access with a short expiry is the right call.
The split worth making is by sensitivity rather than by convenience. Profile images: presign, short expiry, done. Documents containing customer data: proxy, or at minimum bind the link to a session and keep the expiry in minutes. Most teams apply one policy to everything, and it is usually the one chosen for the least sensitive case.
The implication
The signature is a capability you minted. Everything about it, what it permits, for how long, and who can use it, was decided at signing time by code that usually got less attention than an equivalent API endpoint would have.
Read that signing function as though it were a permission grant, because that is exactly what it is.