Presigned URLs for S3

boto3 · Practical · 10 min

Serving and receiving files through your own servers is slow and expensive; presigned URLs hand the transfer to S3 securely.

A presigned URL is a normal S3 URL with a signature in its query string, created locally with your credentials. Anyone holding it can perform that one operation until it expires, without AWS credentials of their own.

  • Downloads: s3.generate_presigned_url("get_object", Params={"Bucket": b, "Key": k}, ExpiresIn=900). Add "ResponseContentDisposition": 'attachment; filename="report.pdf"' to force a download name.
  • Uploads: generate_presigned_url("put_object", Params={..., "ContentType": "image/png"}); the client must then send exactly the signed headers. For browser form uploads with size and type limits, generate_presigned_post(Bucket, Key, Conditions=[["content-length-range", 0, 10_000_000]], ExpiresIn=600) returns a URL plus form fields.

Things to know:

  • Generating a URL makes no network call and doesn't check that the object exists or that you have permission; those are checked when the URL is used, against the signer's permissions at that time.
  • The URL can't outlive the credentials that signed it: URLs signed with temporary role credentials (Lambda, ECS, SSO) stop working when the session expires, even if ExpiresIn is longer. The maximum is 7 days.
  • Create the client with the bucket's region and Config(signature_version="s3v4"). botocore still signs S3 presigned URLs with the legacy SigV2 scheme unless told otherwise, and SigV4 is what new regions and security policies require.

Treat presigned URLs like passwords: short expiry, HTTPS only, and don't log them.

Example

import boto3, requests
from botocore.config import Config

s3 = boto3.client("s3", region_name="us-east-1", config=Config(signature_version="s3v4"))
s3.create_bucket(Bucket="demo-share-2026")
s3.put_object(Bucket="demo-share-2026", Key="reports/q1.pdf", Body=b"%PDF-1.7 ...")

url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "demo-share-2026", "Key": "reports/q1.pdf",
            "ResponseContentDisposition": 'attachment; filename="q1.pdf"'},
    ExpiresIn=900)                       # 15 minutes
print(url.split("?")[0])                 # https://demo-share-2026.s3.amazonaws.com/reports/q1.pdf
print("X-Amz-Signature=" in url)         # True: SigV4

post = s3.generate_presigned_post(
    "demo-share-2026", "uploads/avatar.png",
    Conditions=[["content-length-range", 0, 2_000_000]], ExpiresIn=600)
r = requests.post(post["url"], data=post["fields"],
                  files={"file": ("avatar.png", b"\x89PNG...")})
print(r.status_code)                     # 204

Remember

  • generate_presigned_url signs one operation locally, no network call
  • Set signature_version='s3v4'; use generate_presigned_post for browser uploads
  • URLs can't outlive the signing credentials; max 7 days
  • Keep expiry short and treat URLs as secrets

Watch out for

  • Presigning with Lambda's temporary credentials for 7 days and seeing links break after a few hours.

Check yourself

  1. Does generate_presigned_url fail if the object doesn't exist?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: boto3.amazonaws.com/v1/documentation/api/latest/guide/s3-presigned-urls.html

Builds on

Unlocks

Nothing builds on this yet.