S3 Presigned URLs Explained: Secure Temporary Download and Upload Access

How do you let someone download or upload a private S3 object without making the bucket public? That is the exact problem S3 presigned URLs solve. Also sometimes called signed URLs, they give a specific person temporary, credential-backed access to one object. The bucket stays private throughout.

You generate a presigned URL on your backend, return it to the authenticated client, and the client uses it directly against S3. No AWS credentials on the client side, no file traffic through your servers, and access ends automatically when the URL expires.

Simple explanation

An S3 presigned URL has your AWS credentials and a cryptographic signature embedded in the query string. When S3 receives a request using that URL, it unpacks the signature, checks that the credentials had permission to perform the operation at the time of signing, and verifies the URL has not expired.

The person using the URL needs no AWS account or IAM permissions. The authority is encoded in the URL itself. This makes presigned URLs bearer tokens: anyone who holds the URL can use it until it expires.

S3 Buckets Explained covers how S3 access normally works through IAM. Presigned URLs are a way to temporarily delegate that access to someone outside your AWS account without changing any bucket permissions.

🎟

Analogy

Think of a presigned URL like a temporary visitor pass to a private building. Security checks the pass, not you personally. The pass has credentials and an expiry timestamp stamped into it. Once the time runs out, the pass is rejected at the door. The building and everything inside it remain untouched.

What problem presigned URLs solve

Amazon S3 Overview explains that S3 buckets are private by default. Only IAM principals with explicit permissions can access objects. That is the right default for most data: user uploads, invoices, generated reports, backups.

But you often need to give someone outside your AWS environment access to one specific file. Without presigned URLs, your options are:

  • Make the bucket or object public. Permanent access, no expiry, no tracking. Not suitable for private data.
  • Route downloads through your server. You fetch from S3 and stream to the client, wasting bandwidth and compute at scale.
  • Give the recipient AWS credentials. Completely impractical for end users.

Presigned URLs solve this. You grant access to one object for a defined time window, without changing any IAM policies or bucket policies, and without the recipient needing an AWS account.

How S3 presigned URLs work

When you call generate_presigned_url in boto3, or aws s3 presign in the CLI, the SDK takes your current AWS credentials and signs the request parameters using AWS Signature Version 4. The signed values are appended to the URL as query string parameters.

The query string includes:

  • X-Amz-Credential: the access key ID, date, region, and service scope
  • X-Amz-Expires: how long the URL is valid, in seconds from signing time
  • X-Amz-SignedHeaders: which request headers are covered by the signature
  • X-Amz-Signature: the HMAC-SHA256 signature

When the URL is used, S3 recomputes the expected signature from those parameters. If the computed signature matches and the URL has not expired, S3 serves or accepts the object. If expired, S3 returns 403 Forbidden.

The signing happens entirely in your backend. S3 never sees your raw credentials; it only sees the signed URL. This means generating a presigned URL requires no S3 API call, just a local cryptographic computation.

The HTTP method is part of the signature. A URL signed for GET cannot be used for PUT, and vice versa. Any headers listed in X-Amz-SignedHeaders must be present in the actual request with the exact same values. A mismatch returns SignatureDoesNotMatch.

The signing entity is typically an IAM role attached to your backend service (Lambda, ECS task, EC2 instance, or similar). The role’s permissions are checked at access time, not just at signing time. If the signing entity loses s3:GetObject after the URL is generated, the URL stops working immediately.

Typical request flow

  1. User authenticates to your application (login, session token, or API key)
  2. User requests access to a specific file (for example, clicking “Download invoice”)
  3. Your backend checks whether this user is authorised to access that S3 object
  4. Your backend generates a presigned URL for that exact object key and operation (GET or PUT)
  5. Your backend returns the URL to the client (in a JSON response, a redirect, or an anchor link)
  6. The client uses the URL directly against S3. Your servers are not in the data path.
  7. S3 validates the signature and serves or accepts the object
  8. Access ends automatically when the URL or the signing credentials expire

Your application owns step 3. Presigned URLs do not replace your app’s access control: they are how you hand off a specifically authorised S3 operation to the client.

Watch out

If you generate a presigned URL using temporary credentials (an IAM role used by Lambda, ECS, EC2, or any assumed role via STS), the URL expires when those credentials expire. Not when your ExpiresIn value says. If you set ExpiresIn=86400 but the role session only has 30 minutes left, the URL stops working after 30 minutes.

When to use presigned URLs

  • User-specific file downloads. Invoices, reports, exports, or media files that belong to one account.
  • Direct browser uploads. Let users upload files straight to S3 without routing through your backend.
  • Mobile app asset delivery. Private images or documents served from a private bucket.
  • Partner or customer data sharing. A time-limited link to retrieve a specific dataset.
  • Server-to-server transfers without IAM credentials. When a consuming service cannot assume an IAM role.
  • CI/CD artefact access. Build artefacts in a private bucket, accessible for a short download window.
Practical tip

For one-off downloads, keep expiry under 15 minutes. For shared reports where someone might open the link later in the day, 2–4 hours is a reasonable ceiling. Never set 7-day expiry unless the content is genuinely low-sensitivity and you understand the leak risk.

When not to use them

  • Genuinely public content. Static assets like CSS files, open-source downloads, and public documentation need no access control. A public bucket or CloudFront is simpler and more efficient.
  • High-traffic downloads. Every presigned URL is unique per signature, so CDNs cannot share a cache entry across users. For content served to many users simultaneously, use CloudFront.
  • Content requiring CDN caching. The unique query parameters in a presigned URL defeat CDN cache key matching.
  • Long-lived programmatic access. If a service needs ongoing S3 access, give it proper IAM permissions rather than regenerating short-lived URLs.

Download vs upload vs presigned POST vs CloudFront signed URLs

These mechanisms are frequently confused. Here is how they differ:

MethodBest forSupports upload?CDN-friendly?Access expires?Complexity
Public S3 objectTruly public static assetsNoYesNoVery low
Presigned GET URLPrivate file download for one userNoNo (unique per request)YesLow
Presigned PUT URLDirect single-file upload from clientYes (one file, fixed key)NoYesLow
Presigned POSTBrowser form upload with policy constraintsYes (with size/type rules)NoYesMedium
CloudFront signed URLPrivate CDN-cached file deliveryNoYesYesMedium
CloudFront signed cookiesPrivate access to many files (e.g. video streaming)NoYesYesHigher

Presigned PUT vs presigned POST. A presigned PUT lets the client upload exactly one file to a fixed S3 key with a fixed content type. It is the simplest upload path. A presigned POST (generated with generate_presigned_post in boto3) uses an HTML form POST and supports policy conditions including maximum file size, allowed content types, and key prefixes. Use presigned PUT for most backend upload flows; use presigned POST when you need server-side upload validation in a browser form.

Presigned S3 URL vs CloudFront signed URL. CloudFront signed URLs pass through CloudFront’s CDN, meaning the response can be cached and served from an edge location close to the user. S3 presigned URLs go directly to the S3 regional endpoint. Use CloudFront signed URLs when cache performance matters or when serving large media files globally.

Generate a presigned download URL with the AWS CLI

The aws s3 presign command signs a GET URL using your current CLI credentials. See Uploading Files with AWS CLI for CLI setup and credential configuration.

# Presigned download URL valid for 1 hour (3600 seconds)
aws s3 presign s3://my-company-assets/invoices/invoice-2025-001.pdf \
  --expires-in 3600

# Output: a single URL with the signature in the query string
# https://my-company-assets.s3.eu-west-2.amazonaws.com/invoices/invoice-2025-001.pdf
#   ?X-Amz-Algorithm=AWS4-HMAC-SHA256
#   &X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250316%2Feu-west-2%2Fs3%2Faws4_request
#   &X-Amz-Date=20250316T120000Z
#   &X-Amz-Expires=3600
#   &X-Amz-SignedHeaders=host
#   &X-Amz-Signature=abc123...

# Valid for 24 hours
aws s3 presign s3://my-company-assets/reports/annual-2024.pdf \
  --expires-in 86400

The resulting URL can be opened in a browser, embedded in an email, or used with curl. The recipient needs no AWS credentials.

Generate presigned URLs with Python boto3

In a real application, you generate presigned URLs in your backend and return them to authenticated users. The boto3 method is generate_presigned_url.

Download URL (GET):

import boto3
from botocore.exceptions import ClientError

def generate_download_url(bucket_name, object_key, expiry_seconds=3600):
    s3_client = boto3.client('s3', region_name='eu-west-2')
    try:
        url = s3_client.generate_presigned_url(
            'get_object',
            Params={'Bucket': bucket_name, 'Key': object_key},
            ExpiresIn=expiry_seconds
        )
        return url
    except ClientError as e:
        print(f"Error generating presigned URL: {e}")
        return None

# Return this URL to the authenticated user only
url = generate_download_url('my-company-assets', 'invoices/invoice-2025-001.pdf')

Upload URL (PUT):

def generate_upload_url(bucket_name, object_key, content_type='application/pdf', expiry_seconds=300):
    s3_client = boto3.client('s3', region_name='eu-west-2')
    url = s3_client.generate_presigned_url(
        'put_object',
        Params={
            'Bucket': bucket_name,
            'Key': object_key,
            'ContentType': content_type,
            # Optional: enforce integrity verification on upload
            # 'ChecksumSHA256': expected_checksum,
        },
        ExpiresIn=expiry_seconds
    )
    return url

When you include ContentType in the Params, S3 requires the client to send a matching Content-Type header on the PUT request. A mismatch returns SignatureDoesNotMatch. For uploads where file integrity matters, you can include a ChecksumSHA256 parameter and S3 will verify the checksum before accepting the object.

Using an upload URL from curl or the browser

Once your backend returns a presigned PUT URL, the client sends a standard HTTP PUT request. No AWS SDK or credentials needed.

# Upload a file using a presigned PUT URL
curl -X PUT \
  -H "Content-Type: application/pdf" \
  --upload-file ./invoice-2025-001.pdf \
  "https://my-company-assets.s3.eu-west-2.amazonaws.com/invoices/invoice-2025-001.pdf?X-Amz-Algorithm=..."

In a browser using the Fetch API:

// presignedUrl received from your backend API
await fetch(presignedUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/pdf' },
  body: file,  // File object from <input type="file">
});

The Content-Type header must exactly match the value signed into the URL. If ContentType was not included at signing time, omit the header from the request as well.

Security guardrails and expiry limits

Important

Presigned URLs are bearer tokens. Anyone who obtains the URL can use it until it expires. A URL forwarded in an email, captured in a browser history, or logged by a proxy is accessible to anyone who sees it. There is no user-identity check at access time.

Credential type determines the maximum expiry:

Credential typeMax URL expiryNotes
IAM user (long-term access key)7 days (604,800 s)CLI/SDK only. The S3 console caps validity at 1 hour regardless of your IAM user credentials.
IAM role / STS temporary credentialsCredential lifetimeTypically 1 hour by default; configurable up to 12 hours. URL expires when the credentials expire, even if ExpiresIn was set longer.
EC2 instance profileCredential lifetimeInstance metadata credentials rotate approximately every 6 hours.
S3 console1 hour maximumConsole-generated URLs have a lower ceiling than CLI/SDK-generated ones.

Practical rules:

  • Keep expiry times as short as the use case allows. Use 5–15 minutes for single-file downloads, a few hours for shared reports, and never set days unless the content is genuinely low-sensitivity.
  • Validate the user in your application before generating the URL. Presigned URLs do not replace your app’s authentication.
  • Generate URLs for specific object keys, not broad prefixes or wildcards.
  • Do not log presigned URLs in application logs. They are credentials.
  • Leaked URLs can be reused until they expire. If a URL is compromised, the only effective remediation is to deactivate the signing credentials.
  • For uploads, include ContentType in the signed parameters so the client cannot substitute a different file type.
  • AWS KMS handles server-side encryption of stored objects independently of presigning. Clients uploading via presigned PUT do not need to interact with KMS.

Policy-based signature age controls: You can add an aws:signatureAge condition to your S3 bucket policy to reject presigned URLs older than a specified number of seconds, regardless of the URL’s own expiry parameter. This lets the bucket owner enforce a hard ceiling on effective URL age across all requests to that bucket.

See S3 Security Best Practices for the full bucket security picture, including Block Public Access and encryption.

Common mistakes

  1. Setting expiry times that are too long. A presigned URL valid for 7 days that gets forwarded or accidentally leaked is accessible by anyone who has it. Match the expiry to the actual need: 15 minutes for a single-file download, a few hours at most for a shared report.
  2. Assuming temporary credentials respect the URL’s ExpiresIn parameter. If you generate a presigned URL using STS temporary credentials (from a Lambda function, ECS task, EC2 instance profile, or any assumed role), the URL expires when those credentials expire, not when your ExpiresIn value says. Set ExpiresIn to something shorter than the remaining credential lifetime, or use long-term IAM user credentials for longer-lived URLs.
  3. Treating presigned URLs as a substitute for application authentication. Presigned URLs grant access to specific S3 objects, not to your application. Always verify in your backend that the authenticated user is allowed to access the object before generating the URL.
  4. Using presigned URLs for high-traffic or public content. Every presigned URL includes a unique signature. CDNs cannot cache responses keyed to unique URLs. For content served to many concurrent users, a public bucket or CloudFront distribution is the right choice.
  5. Mismatched Content-Type on presigned PUT uploads. If you include ContentType in the signed parameters, the client must send the exact same Content-Type header. A mismatch returns SignatureDoesNotMatch. Align the content type at signing time and upload time.

Troubleshooting presigned URL failures

Presigned URL failures almost always surface as 403 Forbidden or SignatureDoesNotMatch. See S3 Access Denied Errors for a broader guide to S3 permission failures. The causes specific to presigned URLs:

SymptomLikely causeFix
403 Forbidden on a URL that worked earlierURL has expiredGenerate a new presigned URL and retry
URL expires much sooner than ExpiresIn suggestsSigned with temporary STS credentials. URL expires when the credentials expire, not when ExpiresIn says.Use an ExpiresIn value shorter than remaining credential lifetime; or switch to long-term IAM user credentials for longer-lived URLs
SignatureDoesNotMatchRequest headers do not match signed headers; or the URL was double-encoded in transitEnsure Content-Type and any other signed headers exactly match; do not URL-encode the presigned URL a second time
Wrong HTTP method errorClient sent GET but URL was signed for PUT, or vice versaMatch the HTTP method to the operation the URL was signed for
403 Forbidden immediately after generationThe IAM entity that signed the URL does not have s3:GetObject or s3:PutObject permission on the objectCheck IAM policies and bucket policies for the signing entity. The signer must have the permission at access time, not just at signing time.
403 Forbidden from bucket policy despite valid signatureA bucket policy condition such as aws:signatureAge is rejecting the URLReview bucket policy conditions; generate a fresh URL with a shorter expiry
Note

If the signing principal’s credentials are deactivated or the role loses its S3 permissions after a URL is issued, every presigned URL signed with those credentials stops working immediately, even those that have not yet expired. This is the only path to early invalidation.

Frequently asked questions

What happens when a presigned URL expires?

Once the expiry time passes, S3 returns an HTTP 403 Forbidden response. The object itself is unaffected; only the URL token is invalid. You can generate a new presigned URL at any time.

Can I use a presigned URL for uploads?

Yes. You can generate a presigned PUT URL that lets a client upload directly to S3 without routing the file through your servers. For more control over upload conditions such as file size limits and allowed content types, use a presigned POST instead.

Can I invalidate a presigned URL before its expiry?

There is no direct revocation mechanism for presigned URLs. To prevent further use before expiry, deactivate or delete the IAM user access key, or revoke the signing role's s3:GetObject or s3:PutObject permission. This immediately stops all URLs signed with those credentials.

Can a presigned URL be used more than once?

Yes. A presigned URL can be used multiple times until it expires. There is no built-in single-use enforcement. If you need one-time access, track issued URLs in your application and refuse to reissue the same URL.

What is the maximum expiry time, and why do temporary credentials change it?

With IAM user long-term credentials, the maximum is 7 days. With temporary credentials from an IAM role or STS (used by Lambda, ECS, EC2, or any assumed role), the URL expires when the underlying credentials expire, even if you requested a longer ExpiresIn value. Temporary credentials typically last 1 hour by default and up to 12 hours depending on the role session configuration.

Last verified: 5 April 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.