Single-Use S3 Presigned URLs: Fix the Credential, Not the Timer
A platform team I reviewed last year had read the AWS guidance correctly and drawn exactly the wrong lesson from it. They knew presigned URLs expire, so they set every download link to the shortest window their retry logic could tolerate and called the access problem solved. Then a support engineer forwarded one of those links into a shared ticket thread to help a customer, the customer's CDN cached it, and a medical scan stayed pullable by anyone in that thread until the timer ran out.
No failed login, no anomalous IAM call, no alarm. The URL did exactly what it was designed to do. They had the headline right, that presigned URLs are time-boxed, and the conclusion wrong, that a short timer is the control. The control is whether you can revoke the thing at all.
On 6 May 2026 AWS published a Terraform-deployable pattern that addresses the right layer of this. Instead of shipping a presigned URL and hoping nobody reuses it, you hand the client a single-use token. The client exchanges that token, exactly once, for a presigned URL with a very short lifespan. The token is the durable artifact; the URL is disposable.
I have built variations of this with customers at Rabata.io for years, and the design is sound. But most write-ups of it bury the one thing that actually determines whether it works, and oversell two things that do not. Let me walk through where the emphasis belongs.
The pattern stacks four services you already know: API Gateway, Lambda, DynamoDB, and S3. It is worth your time because it moves the security boundary off a value you cannot revoke (a signed URL, valid until its timer runs out) and onto one you can (a database row).
The seven-day ceiling is a symptom of a deeper problem
The headline constraint everyone repeats: a presigned URL signed with IAM user credentials maxes out at 604,800 seconds, seven days. People treat that ceiling as the enemy. It is not. The real failure is that a presigned URL is a bearer credential. Whoever holds the string is authorized, for as long as the timer allows, with no second check. Seven days is just a long time to hold a key you cannot take back.
And the seven-day number is often the *best* case rather than the worst. The ceiling only applies to long-lived IAM user credentials. If your URL is signed by temporary credentials, an STS AssumeRole session or an EC2 instance profile, the URL dies when the underlying session does, regardless of the expiry you asked for. STS sessions default to roughly an hour; instance-profile credentials rotate on the order of six hours. So you get the worst of both: a URL that can outlive your intent when signed one way, and one that silently expires early when signed another. The inconsistency is what burns operators, far more than the absolute ceiling does.
The token-exchange pattern sidesteps the whole argument. The long-lived thing is now a token you control in DynamoDB, gated by a database write you can invalidate on first use. The presigned URL it vends can be short precisely because it only has to survive one redemption.
Why the database write is the entire design
If you take one thing from this, take this: the security of this pattern lives in a single DynamoDB conditional write, and nowhere else.
Here is the mechanism, stated plainly. The client presents a token. A Lambda function reads the token row and checks whether it has been used. If two requests arrive within milliseconds of each other for the same token, whether a deliberate replay or a double-tap, a naive read-then-write loses. Both reads see "unused," both proceed, both get a URL. The conditional write closes that gap. The update only commits if the item is still in its expected state; the first request flips the row and succeeds, the second fails its condition and gets a 403. This is optimistic concurrency, enforced by the database, with no external lock and no lock to leak.
Strip the conditional write and you do not have a single-use token. You have a token that is single-use *most of the time*, which under a determined attacker is the same as not single-use at all. So when I review one of these deployments, I do not start with the IAM policy or the WAF rules. I start by asking to see the exact conditional expression on the update, and a test that fires two concurrent redemptions and asserts exactly one 200 and one 403. If that test does not exist, the architecture diagram is decoration.
A second, quieter design choice matters here too. The token vending machine never proxies the file. Lambda's request and response payloads are capped at 6 MB, so routing object bytes through the function is a dead end for anything real. The pattern dodges this by redirecting the client straight to S3, typically an HTTP 302, so the heavy transfer goes object-to-client and the compute layer only ever moves tokens and a URL string. That single decision is why the design scales at all.
What the standard write-up gets wrong
Two claims travel with this pattern that you should not repeat.
The first is the "30-second URL." You will see the architecture described as vending URLs that "last only 30 seconds," stated as if that were the design's value proposition. It is not. The source is explicit that the presigned URL expiration is *configurable at deployment time* and should be "very short"; it never names a figure. Thirty seconds is a generic aggressive default from the wider literature; this solution has no such fixed property. Write it into your runbook as a system fact and the next operator inherits a number nobody chose. Set the expiry deliberately for your file sizes and retry behavior, then document why.
The second is the instinct to scale the token store with "read replicas." DynamoDB does not expose read replicas the way a relational primary does; that vocabulary does not map. If your token table is under pressure, the levers are on-demand capacity, partition-key design that spreads tokens evenly, and the one that matters most, making sure you are not proxying file bytes through Lambda in the first place. Reach for a relational scaling pattern on a key-value store and you will tune the wrong thing.
A pre-deployment checklist that catches the real failures
I run a deployment review against the decisions behind a stack rather than its service names. Walk this list before you promote any token-vending stack to production, in order, because each item gates the one below it:
- Conditional write. Confirm the update is guarded by a condition on the item's current state. Without it, a replay succeeds and "single-use" is fiction.
- Concurrency test. Fire two simultaneous redemptions and assert exactly one 200 and one
- No test means you ship the race condition undetected.
- URL expiry. Set the presigned-URL lifetime deliberately and record the reason. Inherit a magic number and you risk one too short to survive a retry or one long enough to defeat the point.
- Signing identity. Know which credential signs the URL, whether IAM user, STS, or instance profile. Get this wrong and you face surprise early expiry, or a URL that lives longer than you intended.
- File transfer. Verify the client is redirected to S3 rather than streamed through Lambda. Miss it and the 6 MB payload wall stops the system from moving real files.
- Encryption at rest. Put S3 and DynamoDB on a customer-managed KMS key. Default keys leave you without a granular audit trail for sensitive data.
- Network boundary. If the data warrants it, place the Lambda in a VPC behind a private API. Leave this unaddressed and the token-vending logic sits on the public internet by default.
Encryption and network isolation are the two the AWS post flags as hardening, and they are easy to defer and never do. A customer-managed KMS key on the bucket and the table is the difference between "encrypted" and "encrypted with an audit trail you can hand a compliance reviewer." Attaching the Lambda to a VPC with a private API endpoint keeps the exchange off the open internet. Neither is mandatory; both are cheap insurance for medical or financial objects, which is exactly the data this pattern is sold for.
Storage class is the cost lever nobody pulls
Objects fronted by these tokens are frequently regenerable or short-lived: temporary exports, derived artifacts, logs. For that class of data, S3 One Zone-IA costs about $0.01/GB/month, roughly 57% less than Standard, because it holds a single availability zone instead of copies across several. The catch is obvious: a single zone is a single point of loss, so this tier is only for data you can recreate.
The wider point is the spread. The gap between the cheapest and most expensive S3 storage classes runs as high as 23x, which makes storage class one of the largest and least-examined cost levers in the stack. And the durability math is settled per-object, never per-link: a short URL lifetime does nothing about a lost zone, so do not let the security story talk you into the cheap tier for data you actually need.
About
I am Marcus Chen, a Cloud Solutions Architect and Developer Advocate at Rabata.io. My days run on S3-compatible object storage and the access-control patterns that ride on top of it, presigned-URL hygiene, lifecycle policies, the TCO math behind picking one storage class over another. Earlier I was a Solutions Engineer at Wasabi and a DevOps engineer at a Kubernetes-native startup, and I hold the AWS Solutions Architect Professional and CKA certifications.
I keep returning to these access patterns because the leaked-link scenario at the top of this piece is not hypothetical. A forwarded URL with days left on its clock is one of the most common ways sensitive objects walk out the door, and it almost never trips an alarm. That gap between what gets logged and what gets exfiltrated is the work I find most worth doing.
Conclusion
The single-exchange token pattern is worth adopting, but adopt it for the right reason. It does not make your URLs cryptographically stronger; it replaces an unrevocable bearer credential with a revocable database row, and that is the real win. The cryptography is the easy part of this. The discipline around the token store and the credential that signs the URL is the part that decides whether you have actually closed the leak or just moved it.
So before you adopt it, do this next: open the deployment you already run, or the one you are about to ship, and write the two-concurrent-redemptions test that asserts one 200 and one 403. Run it. If it is red or missing, you have found your first work item, and you have found it before an attacker did.
Frequently Asked Questions
A short expiry still leaves a bearer credential valid for its whole window, and you cannot revoke it once issued. A single-use token decouples the durable, revocable artifact (the token in DynamoDB) from the disposable one (the URL), so reuse is blocked by a database state change rather than only by a timer you cannot take back.
A DynamoDB conditional write. The update to mark a token used only commits if the row is still in its expected unused state, so among concurrent requests exactly one succeeds and the rest fail their condition and receive a 403. This is the single mechanism the whole pattern depends on, and it should be covered by a concurrency test.
No. The source states the URL expiration is configurable at deployment time and should be very short, but never fixes a number. Thirty seconds is a generic best-practice default from the wider literature, not a property of this solution. Choose the expiry deliberately based on your file sizes and retry needs.
Lambda caps both request and response payloads at 6 MB, so proxying object bytes through the function fails for any real file. The pattern redirects the client straight to S3, usually via an HTTP 302, which keeps the heavy transfer object-to-client and lets the compute layer move only tokens and a URL string.
When the data is regenerable or genuinely non-critical, such as temporary exports or logs. One Zone-IA is about 57% cheaper than Standard at roughly $0.01/GB/month, but it stores a single availability zone, so a zone loss means data loss. A short URL lifetime does not change that, so never use it for data you cannot recreate.