S3 Presigned URLs Aren’t Single-Use (Here’s How to Make Them)
An S3 (or Cloudflare R2) presigned URL is reusable until it expires: it is time-limited, not use-limited. Anyone who has the link can download the object again and again until the expiry timestamp passes - there is no built-in “this link works exactly once” option. If you need a download link that dies after the first fetch, you have to build that enforcement yourself, or hand it to a service that does it. This article covers why presigned URLs work this way, where it bites you, the three DIY fixes and what they actually cost, and a one-POST API that returns a link which returns 410 Gone after the first download.
The problem: presigned ≠ single-use
A presigned URL is just a normal request to your bucket with the authentication baked into the query string. When you call get_presigned_url (or GetObjectCommand + getSignedUrl in the JS SDK), S3 computes a SigV4 signature over the request and an expiry, and packs them into ?X-Amz-Signature=…&X-Amz-Expires=…. The signature proves the request was authorized; the X-Amz-Expires value (1 to 604800 seconds, i.e. up to 7 days) caps how long it stays valid.
The AWS documentation is explicit about the consequence: the URL can be used to make requests until it expires. There is no request counter in the signature and no place to put one. S3 has no idea whether this is the first GET or the fiftieth - it only checks that the signature is valid and the clock hasn’t run out. R2’s S3-compatible presigning behaves identically. So “presigned” means “temporarily authorized,” not “redeemable once.”
Why this bites you
The gap matters because links don’t stay between two people. A presigned URL you generate for one recipient ends up in more places than you intended, and each copy works for the full expiry window:
- Server and proxy logs. The full URL - signature and all - lands in access logs, CDN logs, APM traces, and load-balancer logs. Anyone with log access can replay it.
- Chat and email. Paste it into Slack, Teams, or an email and it’s now in message history, search indexes, and link-preview crawlers that fetch the URL automatically.
- Browser history and referrers. The link sits in history, and if the download page links onward, the URL can leak via the
Refererheader. - Forwarding. The recipient forwards “here’s the file” to a colleague, and now two, three, ten people can pull it for the rest of the window.
None of these are exotic. For a 7-day link to an invoice, contract, or export, “valid until expiry” quietly means “unlimited re-downloads by anyone who ever saw the link, for a week.” Shortening the expiry helps a little, but a 60-second link is unusable for a human and still re-fetchable several times inside that minute.
The DIY fixes - and what they cost
You can make S3 downloads single-use. Every approach works by putting state you own in front of the object, because S3 itself won’t track uses. Three common patterns:
| Approach | How it works | What it costs you |
|---|---|---|
| App as proxy + DB counter | Hand out a link to your endpoint, not to S3. On hit, check/decrement a counter in your database, then stream the object (or 302 to a short-lived presigned URL). | Your app is now in the download path - bandwidth, timeouts, and large-file streaming are your problem. Plus a DB row per link. |
| CloudFront + Lambda@Edge | Front the bucket with CloudFront and run a Lambda@Edge function on each request to validate a token and enforce a use count. | Edge function code, a distribution, and a low-latency store the edge can read/write. More moving parts to deploy, version, and debug. |
| DynamoDB token w/ TTL | Issue an opaque token, store it in DynamoDB with a TTL and a uses attribute, and conditionally delete/decrement it on redemption. |
A table, a TTL config, and careful conditional writes. DynamoDB TTL deletes are eventually consistent (can lag by up to ~48h), so you still need a guard at read time. |
All three are buildable in an afternoon and a maintenance burden forever: a counter store, the redemption logic, the failure modes (what happens on a half-finished download?), and an infra footprint that now sits on your critical path. The hard part isn’t the happy path - it’s making the decrement atomic so two parallel requests can’t both think they’re the first.
What you actually want: an atomic use counter
Strip away the infrastructure and the requirement is small: a token, a maximum number of uses, an atomic decrement on redemption, and a hard 410 once the count hits zero. In pseudo-code:
POST /sends { file, max_uses: 1, expires_in } → returns { token }
GET /sends/<token>:
# atomic: one winner under concurrency
remaining = ATOMIC_DECREMENT_IF_POSITIVE(token.uses)
if remaining is None: # already 0, or token unknown
return 410 Gone
if now() > token.expires_at:
return 410 Gone
stream_object(token.file) # decrement already committed
The ATOMIC_DECREMENT_IF_POSITIVE is the whole game - a conditional update that returns the new value only if it was above zero, so concurrent requests can never both succeed. Everything else (storage, the branded download page, resumable ranges) is plumbing around that one guarantee. This is exactly the plumbing the DIY approaches above force you to write and operate.
A single-use signed-URL API
Bottleneck is that plumbing as a hosted endpoint. One authenticated POST uploads a file and returns a link that is single-use enforced server-side: set max_uses=1 and the link returns 410 Gone the instant the first download completes - no Lambda, no edge function, no counter table on your side. expires_in sets a TTL, an optional password gates the download, and image/PDF metadata (EXIF, GPS, document properties) is stripped server-side before delivery so the recipient never gets the original’s hidden fields. We process the upload in memory and purge the stored file after delivery rather than keeping it around.
A few honest constraints, because they’re design choices: practical uploads run to roughly 100 MB through the API, executables are blocked, and every send is tied to a verified sender with a report-abuse path. That makes this accountable, safe delivery - not anonymous delivery. The size cap is deliberate, not a number to push against.
Code example
Upload a file and demand a one-time link with a one-hour expiry and a password:
curl https://apis.bneck.com/api/v1/sends \ -H "Authorization: Bearer bn_xxx" \ -F [email protected] \ -F max_uses=1 \ -F expires_in=3600 \ -F password=hunter2
The response is JSON - a ready-to-share url, the enforced max_uses, an expires_at millisecond timestamp, confirmation that metadata was stripped, and the credits charged:
{
"status": 200,
"url": "https://apis.bneck.com/sends/ab12cd34",
"id": "snd_42",
"max_uses": 1,
"expires_at": 1780000000000,
"metadata_stripped": true,
"credits_charged": 1
}
Hand url to your recipient. The link serves a branded download page; the file downloads once - a range/resume request inside a ~10-minute window counts as the same single use, so a flaky connection doesn’t burn the link - and after that it returns 410 Gone. Billing is prepaid credits, one credit per send, so there’s no metered surprise. Full parameters and the strict /api/v1/links shortener (which counts every hit with no grace window) are in the API docs.
Home · API docs & pricing · Send a file that deletes after download