What S3 Presigned URLs Actually Do

Deleting the IAM user who generated a presigned URL does nothing — the URL keeps working until the access key itself is deleted or deactivated, and most teams don’t discover that until a security audit flags it. Understanding why requires looking at how the signature actually works.
A presigned URL is not a token. There is no session record on AWS’s side. It is a standard SigV4-signed request where all parameters — bucket, key, HTTP method, expiry timestamp, query parameters — are encoded into the URL and signed with the credentials of whoever generated it. AWS receives the URL, recomputes the signature using the same key, and either matches or rejects it. No lookup. No revocation list.
What gets locked into the signature at generation time: the bucket name, the object key, the HTTP method, the expiry timestamp (X-Amz-Expires), the signing date, the region, and any additional query parameters like ResponseContentDisposition. Change any of these after generation and the signature breaks.
The signing principal matters enormously for the TTL ceiling. When you sign with an IAM user’s access key, the maximum TTL is 7 days (604800 seconds). That key stays valid indefinitely unless you explicitly rotate or delete it. When you sign with an IAM role or instance profile, the URL is bounded by the STS session duration — maximum 12 hours (43200 seconds). The session expires, the URL stops working, regardless of what ExpiresIn you set. This is not a minor implementation detail. It is the most important architectural decision in your presigned URL design.
The other consequence of this model: the URL itself is the credential. There is no bearer token to invalidate, no session to terminate. If the URL leaks, the only remediation is to invalidate the signing key — which may break every other URL signed with it.
How People Use Presigned URLs Wrong
I’ve seen four patterns come up repeatedly in production systems, and each one creates real exposure.
Long TTLs because it’s “easier for the client.” Generating a URL with a 7-day expiry and embedding it in an email or Slack message is functionally a public link. The recipient can forward it, it gets indexed by link-preview bots, it sits in log files. I stopped using TTLs above 15 minutes for downloads after we found a URL in a support ticket that had been copy-pasted into three different systems.
Signing with a long-lived IAM user key. This is the most dangerous pattern. The URL survives IAM user deactivation. It survives password resets. The only way to kill it is to delete or deactivate the specific access key. Most incident response runbooks don’t account for this. The fix is structural: use roles, not users.
Assuming HTTPS is enforced. It is not. A presigned URL works over plain HTTP unless you explicitly deny it at the bucket policy level. The URL contains no transport enforcement — that has to come from the bucket.
Passing the URL through logging systems. The URL is the credential. If your application logs the full presigned URL to CloudWatch, Datadog, or any log aggregator, you’ve just stored a credential in your log pipeline. Log the key, the TTL, the expiry timestamp — never the URL itself.
Watch out for this one: the error you get when a presigned URL expires is HTTP 403 AccessDenied with body <Code>AccessDenied</Code><Message>Request has expired</Message> — not a 401. Teams that map 403 to “permission denied” miss that this is actually an expiry condition and surface the wrong error to users.
The Correct Approach: Expiration Strategy and Signing Identity
The baseline I use in production comes down to four controls applied together.
1. Use IAM roles as the signing principal, always. Assume a role with a short session duration. The presigned URL cannot outlive the session. For a download vending service, a 15-minute session is more than sufficient. The role should be scoped to s3:GetObject on the specific key prefix — no s3:ListBucket, no wildcards on the bucket.
2. Set TTL to the minimum viable window. For file downloads: 5–15 minutes. For upload flows: your upload timeout plus a 60-second buffer. Never rely on the boto3 default of 3600 seconds — it’s easy to miss in code review and it’s too long for most use cases.
3. Enforce HTTPS and a server-side TTL cap via bucket policy. The s3:signatureAge condition key enforces a maximum age in seconds on the server side, independent of what the URL generator set. This is your safety net when someone accidentally generates a URL with a longer TTL than policy allows.
4. Tag the signing role session. Use sts:TagSession to embed user context into the session. CloudTrail logs the signing principal ARN — without session tags, you see the role ARN but lose the individual user attribution. With tags, every S3 access event carries the context you need for investigation.
The following pattern implements all four controls. It assumes a role with a 15-minute session, pins the region to avoid path-style endpoint failures in newer regions, sets an explicit TTL, and logs the audit event without logging the URL itself.
This is the core vending function — call it per-request, never cache the output across users:
import boto3
from botocore.exceptions import ClientError
import time
# Use a role-assumed session, not a long-lived IAM user key.
# This bounds presigned URL TTL to the role session duration (max 12h).
sts = boto3.client("sts")
assumed = sts.assume_role(
RoleArn="arn:aws:iam::123456789012:role/s3-presign-vending-role",
RoleSessionName="presign-session",
DurationSeconds=900, # 15-minute session — URLs cannot outlive this
Tags=[
# Tag session so CloudTrail entries carry user context
{"Key": "RequestedBy", "Value": "[email protected]"},
{"Key": "Purpose", "Value": "file-download"},
],
)
creds = assumed["Credentials"]
# Build a scoped client from the temporary credentials
s3 = boto3.client(
"s3",
region_name="eu-central-1", # Always pin the region — avoids path-style endpoint issues
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
)
BUCKET = "my-secure-bucket"
KEY = "uploads/user-42/report-2024.pdf"
TTL = 300 # 5 minutes — set to minimum viable for your use case
try:
url = s3.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": BUCKET,
"Key": KEY,
# Force browser download, not inline render — reduces phishing risk
"ResponseContentDisposition": "attachment; filename=report-2024.pdf",
},
ExpiresIn=TTL, # Never rely on the default (3600s)
HttpMethod="GET",
)
except ClientError as e:
raise RuntimeError(f"Failed to generate presigned URL: {e}") from e
# Log the generation event — log the key and TTL, NOT the URL itself
print(f"[audit] presigned URL issued | key={KEY} | ttl={TTL}s | expires_at={int(time.time()) + TTL}")
# Return URL to caller — never cache or reuse across different users/requests
print(url)
The bucket policy that enforces HTTPS and the server-side TTL cap. Apply this via Terraform or CDK — the two conditions should be in the same Deny statement so either one triggers a deny:
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-secure-bucket/*",
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
},
"NumericGreaterThan": {
"s3:signatureAge": 300
}
}
}
Advanced Patterns: Scoped Access, Audit Trails, and Conditional Delivery
Once the baseline is solid, there are a few patterns worth adding depending on your use case.
Presigned POST for uploads instead of presigned PUT. Presigned POST lets you enforce content-length-range, Content-Type, and key prefix constraints at the S3 policy level — not in your application logic. If your app validates file size before generating the URL but a client bypasses the app, a presigned PUT has no server-side enforcement. A presigned POST policy enforces it on every request regardless of how the client behaves.
CloudFront signed URLs when you need IP binding or geographic restrictions. S3 presigned URLs have no IP binding. If you need to restrict access to a specific IP range or country, pair presigned delivery with CloudFront signed URLs or signed cookies. Note that presigned S3 URLs bypass CloudFront’s cache entirely — every request hits S3 origin. If caching matters, CloudFront signed URLs are the right tool, not S3 presigned URLs with a CloudFront distribution in front.
Short-lived token vending service. The pattern I prefer for any multi-user application: the backend generates a presigned URL on demand per request and never caches or reuses URLs across users. The vending function assumes a fresh role session each time, embeds user context via session tags, and returns a URL with a TTL matched to the specific operation. This gives you full CloudTrail attribution and eliminates the risk of URL reuse.
For more on how we structure IAM role vending patterns in production, see the related work on kuryzhev.cloud.
Performance and Cost Notes
A few things that catch teams off guard when presigned URLs go to scale.
Generating a presigned URL is a local SDK operation. No API call to AWS, no network round trip, no charge. The generate_presigned_url() call computes the signature locally using the credentials in memory. The only AWS call in the pattern above is the sts:AssumeRole — which you should be caching at the session level, not calling per URL.
Every presigned URL access is a real S3 GET or PUT. It counts toward S3 request pricing ($0.0004 per 1,000 GET requests in us-east-1) and data transfer costs. This is fine for normal use. It becomes a problem when someone generates presigned URLs in a loop for bulk downloads — 10,000 objects means 10,000 individual S3 GET requests. For bulk access patterns, S3 Batch Operations or a ZIP-on-the-fly Lambda is the right approach.
The sts:AssumeRole call itself has a cost in latency — typically 50–150ms. For a vending service that generates one URL per user request, this is acceptable. For a service that generates dozens of URLs per page load, cache the assumed role credentials for the session duration and reuse them. The credentials are valid for the full DurationSeconds you specified.
For the full AWS documentation on presigned URL signature behavior, see the S3 presigned URL reference and the STS session tagging documentation for the CloudTrail attribution pattern.
The core rule is simple: treat presigned URLs as credentials, not as links. Generate them as late as possible, make them as short-lived as the use case allows, sign them with a role rather than a user, and never log the URL itself. Everything else is optimization on top of that baseline.
