How to create S3 presigned URLs for downloads and uploads
Generating presigned URLs with the AWS CLI, boto3 and the JavaScript SDK, the credential rule that makes them expire early, presigned POST for browser uploads, and what each error actually means.
Sign with the narrowest role that can do the job, and assume the URL will expire with that role’s session rather than at the lifetime you asked for. Use a presigned PUT for a trusted uploader and a presigned POST with content-length-range for an untrusted one. Cap the whole bucket with s3:signatureAge if links are part of your workflow — and when the requirement includes revocation, recipient identity or a record of who opened what, use something other than a presigned URL.
A presigned URL lets someone download or upload one specific S3 object without an AWS sign-in. You sign a request with your own credentials, hand over the resulting URL, and S3 honours it until it expires.
The mechanics are three lines of code. The parts that cause support tickets are the expiry rules, which depend on the credentials you signed with, and the upload path, where a presigned PUT and a presigned POST behave differently. This guide covers both, plus the errors and what each one is telling you.
What a presigned URL is
It is an ordinary S3 request with the authentication moved into the query string. The URL carries the permissions of the IAM principal who created it, so the recipient can do exactly what that principal could do to that object, and nothing more. Anyone with valid credentials can create one, but it only works if the signer had permission for the operation being signed.
You choose four things when you create it: the bucket, the object key, the HTTP method (GET to download, PUT to upload, HEAD to read metadata) and an expiration interval. The URL can be used more than once until it expires, which is worth saying out loud, because "single use" is a common and wrong assumption. If you presign a PUT for a key that already exists, the upload replaces the existing object.
Treat the URL as a bearer token. AWS says so directly: possession is the whole authorisation. Anything that copies a URL — a forwarded email, a chat log, a proxy access log, a browser history sync — hands over the access with it.
Expiry is two numbers, and the smaller one wins
The lifetime you request is a ceiling, not a promise. The URL also dies when the credentials that signed it stop being valid, and for anything running on AWS those credentials are usually temporary.
Signed with IAM user credentials under Signature Version 4, a presigned URL can be valid for up to 7 days. Signed with a role session, it expires when the session does, even if you asked for longer. An AssumeRole session defaults to one hour. EC2 instance profile credentials from the instance metadata service have a maximum validity of roughly 6 hours. ECS task credentials typically rotate every one to six hours. The S3 console caps its own presigned URLs between 1 minute and 12 hours.
This is the single most reported surprise with presigned URLs, and it has a plain consequence: a Lambda function or a Fargate task cannot mint a link that outlives its own role session, no matter what ExpiresIn says. If you genuinely need a multi-day link, you need long-lived credentials to sign it with, which is a trade most teams should decline rather than engineer around.
S3 checks the expiry when the HTTP request starts. A large download that began just before expiry keeps going; if the connection drops and the client retries afterwards, it fails.
Generating a URL for download
The AWS CLI has aws s3 presign, which signs a GET and nothing else. Its default expiry is 3,600 seconds, and --expires-in takes a value in seconds up to the 7-day maximum. For anything other than a download, use an SDK.
In boto3, generate_presigned_url signs any operation. The useful extra is that you can override the response headers for that one request, which is how a PDF stored with a generic content type is made to open in the browser tab instead of downloading, without modifying the object.
In the JavaScript SDK v3, the signer lives in a separate package, @aws-sdk/s3-request-presigner, and takes a command object rather than a parameter bag.
# CLI: GET only, default 3600 seconds
aws s3 presign s3://amzn-s3-demo-bucket/contracts/acme-msa.pdf --expires-in 900The same thing in boto3
import boto3
s3 = boto3.client("s3")
url = s3.generate_presigned_url(
"get_object",
Params={
"Bucket": "amzn-s3-demo-bucket",
"Key": "contracts/2026/acme-msa",
# Override the stored headers for this response only.
"ResponseContentType": "application/pdf",
"ResponseContentDisposition": "inline",
},
ExpiresIn=900,
)
# Upload: one key, one recipient, replaces the object if it exists.
put_url = s3.generate_presigned_url(
"put_object",
Params={
"Bucket": "amzn-s3-demo-bucket",
"Key": "inbox/signed-contract.pdf",
"ContentType": "application/pdf",
},
ExpiresIn=900,
)Uploads: presigned PUT or presigned POST
A presigned PUT URL is the simple case. The recipient sends the file body to the URL and it becomes the object. You control the key, because you signed it, and you can pin the content type by signing it too. What you cannot control is size: nothing stops someone sending a 40 GB file to a URL you meant for a signed contract.
A presigned POST is the browser-form case, and it is the one to reach for when the uploader is untrusted. Instead of a bare URL you generate a URL plus a set of form fields containing a signed policy, and that policy can carry conditions. content-length-range caps the file size before a byte is accepted. starts-with on the key lets the client choose a filename inside a prefix you control. In boto3 it is generate_presigned_post.
Under Signature Version 4 you can also require a checksum on upload — CRC-32, CRC-32C, CRC-64/NVME, SHA-1, SHA-256 and others — by signing the corresponding header, which turns a silent corruption into a rejected request.
post = s3.generate_presigned_post(
"amzn-s3-demo-bucket",
"inbox/${filename}",
Fields={"Content-Type": "application/pdf"},
Conditions=[
{"Content-Type": "application/pdf"},
["starts-with", "$key", "inbox/"],
# Reject anything outside 1 KB to 25 MB before it is accepted.
["content-length-range", 1024, 26214400],
],
ExpiresIn=900,
)
# post["url"] is the form action; post["fields"] are hidden inputs,
# and the file input must come last in the form.Limiting what the holder can do
Because the URL inherits the signer’s permissions, the first control is to sign with a role that has only the permission you intend to delegate. A presigned URL created by an administrator is an administrator’s access to that object.
Beyond that, two bucket-side controls are worth knowing. The s3:signatureAge condition denies requests whose signature is older than a given number of milliseconds, which caps every presigned URL against the bucket regardless of what lifetime the signer asked for. And a network-path restriction using aws:SourceIp, aws:SourceVpc or aws:SourceVpce requires requests to arrive from your network, which applies to presigned URLs along with everything else.
Neither gives you revocation of a single link. Cutting one URL short still means invalidating the credentials that signed it or removing the object, both of which affect far more than the one share.
The errors, and what each one means
- 403 Forbidden: the signing principal lacked the permission for that operation, or a bucket policy explicitly denies it. Check the signer’s permissions before you check the URL.
- ExpiredToken: the temporary credentials used to sign have expired. The URL cannot outlive them; refresh the credentials and sign again.
- SignatureDoesNotMatch: the request does not match what was signed. Usually clock drift on the signing host, a corporate proxy rewriting headers or query strings, or a parameter that differs between signing and use.
- AccessDenied with HeadersNotSigned: if-range: when Range is in the signed headers, S3 requires If-Range to be signed too if it is present in the request.
- The URL works for you and not for the recipient: you are testing while signed in to the console. Try it in a private window, which is the only honest test.
When a presigned URL is the wrong answer
Presigned URLs are an excellent mechanism and a poor process. They give you an expiry and nothing else: no list of outstanding links, no way to revoke one, no recipient identity, and an audit trail that records the signing principal rather than the person who opened the file.
That is fine system to system, fine for a one-off download, and fine for accepting an upload from a form. It is not a client-facing sharing workflow. If the requirement is "the recipient verifies who they are, the link can be withdrawn, and we can see every open", the primitive to build on is a share with an identity attached, not a signature with a timer.
The four ways to reach S3 files without the consoleWhat a share needs before it leaves the company
Starter is free. Deploy a scoped role with CloudFormation, sign in, and browse, without handing anyone an access key.
Primary sources
Discussion
0 comments · open to guests · moderatedLiked this? Get the next article by email. No schedule, no filler, one click to leave.