Your app checks permissions, hands out a presigned URL, and then the permission is removed. The download still works. This post explains why, and what to do about it.
This post is for backend and cloud engineers who let users download files from S3. You do not need to know S3 policies in depth. We start with what a presigned URL actually is, then look at why revoking access afterwards does not stop it, and then at your options, from an emergency fix to a better long-term design. A short note on scope: it builds on the authorization ideas in the access control post, but you can read this one on its own.
A normal S3 request needs AWS credentials. A browser has none, and you do not want to give users any. A presigned URL solves this: your application uses its own credentials to sign a specific request ahead of time, and gives the signed URL to the user. The user can then download the object with nothing else.
A presigned URL looks roughly like this:
https://my-bucket.s3.amazonaws.com/tenant-a/documents/8271.pdf
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=... who signed it (the application's credentials)
&X-Amz-Date=... when it was signed
&X-Amz-Expires=3600 how long it is valid, in seconds
&X-Amz-Signature=... proof that the signer allowed exactly this request
Two ideas matter for the rest of this post:
A typical flow: Bob asks for document 8271, your backend checks that Bob may read it, and it returns a presigned URL valid for 60 minutes. Bob downloads the file directly from S3, so large files never pass through your servers.
Now add one event:
An engineering manager might say "our authorization engine revoked access correctly, so this must be an S3 bug". It is not. The app and S3 each did what they are designed to do:
AWS documents this directly. A presigned URL can be used multiple times until it expires, and S3 checks the expiry time when the request starts. If a download begins just before expiry, it continues even if the time passes during the transfer.
The easy improvement is to shorten the lifetime, for example from 60 minutes to 1 or 5 minutes. This reduces the window in which a revoked user can start a download. It is worth doing, but know its limits:
A guardrail worth adding: your code decides the expiry, and code has bugs. You can make S3 refuse old signatures, whatever the URL says, with a bucket policy condition. AWS gives this example, which denies presigned requests whose signature is more than 10 minutes old (the value is in milliseconds):
{
"Sid": "DenyOldPresignedUrls",
"Effect": "Deny",
"Principal": { "AWS": "*" },
"Action": "s3:*",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": { "NumericGreaterThan": { "s3:signatureAge": "600000" } }
}
There is also a hard ceiling from the credentials: if the URL is signed with temporary credentials (a Lambda role, for instance), it stops working when those credentials expire, even if you asked for a longer expiry.
It sounds sensible to cancel the URL once it has been used. But S3 does not track that. A presigned URL is not single-use, and S3 has no per-URL "used" flag. To get single-use behavior you would need your own system to remember and enforce it, which is a download gateway (covered below).
Suppose a confidential document was shared with the wrong contractor, the owner revoked them, and the contractor still holds a URL with 4 minutes left. You cannot shorten a URL that already exists. You can change what S3 will allow for that object.
AWS evaluates all applicable policies on each request, and an explicit Deny always wins over any Allow. A valid signature does not override it. (AWS's own presigned URL troubleshooting notes tell you to check that the bucket policy does not explicitly deny the object.) Add a statement to the bucket policy:
{
"Sid": "EmergencyBlockDocument8271",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::enterprise-documents/tenant-a/documents/8271.pdf"
}
What to know before using it:
AccessDenied. To confirm what happened afterwards, turn on CloudTrail data events for the bucket. They are not logged by default.A presigned URL is tied to a specific object key. If you copy the object to a new key and delete the old one, the old URL points at nothing. The owner and other authorized users are not blocked for long, because your app issues them a fresh URL for the new key the next time they ask. The tradeoffs: large objects need a multipart copy, versioned buckets keep old versions, and anything that stores the old key must be updated. Test this on a copy before relying on it.
AWS notes that a presigned URL expires when the credentials that created it are revoked or deleted. IAM can revoke a role's existing sessions, which adds a deny for anything issued before that moment. But if every tenant's URLs are signed by the same Lambda role, this stops every customer's in-flight downloads. Use it when the signing credentials themselves leak, not to remove one contractor.
The manager's next question is natural: "Can a bucket policy deny only the contractor's URL and leave everyone else's working?" Look at what each side knows:
| Your application knows | S3 sees on the request |
|---|---|
| Which user asked (Bob, the contractor) | The signing identity (the shared Lambda role) |
| Which tenant, which grant, when it was revoked | The object key |
| Whether the grant is still valid | When the URL was signed, and whether it is expired |
| Request details such as source IP |
Every URL signed by the Lambda role looks the same to S3, whoever holds it. So a policy on the signer's identity would block all of them or none of them. You could deny the contractor's source IP address, but you would need to know it, and they can change it. The real issue is that you have not given S3 a trustworthy way to tell the contractor apart from the other users.
Before choosing, ask the product team one question: does revocation need to take effect immediately, or is a short delay acceptable? The answer picks the design.
| If you need | Use | Cost |
|---|---|---|
| A small delay is fine for ordinary files | Short-lived presigned URLs (1 to 5 minutes), issued only after an authorization check, plus the s3:signatureAge guardrail | Lowest. A bounded window of exposure remains. |
| Immediate, per-user revocation for sensitive documents | A download gateway: the user asks your service, which checks current permissions on every new request and streams from S3 (or hands out a very short URL after the check) | More bandwidth, latency, cost and another service to keep available |
| Per-user control inside S3 itself | Sign with a separate short-lived STS session per user or per grant, so S3 sees a distinct identity you can deny | More credential management and STS calls. Test it carefully. |
| Block one object for everyone, now | Object-level deny or key rotation (section 5) | Emergency only. Does not scale. |
Instead of signing every URL with the one shared role identity, your service assumes the role with a different session name for each user or grant, then signs with those credentials. The request is then evaluated as that specific session, and IAM exposes the session name in aws:userid (in the form ROLEID:session-name). A policy can then deny one session without touching the others. Two cautions: assumed-role sessions last at least 15 minutes, and a URL signed by a session is limited by that session's lifetime. Treat this as a design to prototype and test against your real policies, not a drop-in.
A gateway gives you the strongest control, because every new download passes through your authorization check. Even then, a gateway cannot take back bytes the client already received, and stopping a transfer that is already running needs you to manage and close the connection yourself. If revocation must also end running downloads, check authorization again during streaming, not only at the start.
curl. Decide what the correct result is for your chosen design and write that down.AccessDenied. Remove it and confirm access returns.signatureAge guardrail should refuse it once the signature is old enough.signatureAge cap reduce the window. They do not give instant revocation.