DEV Community

Cover image for S3 Presigned URLs: Security Pitfalls You'll Regret
Ethan Carter
Ethan Carter

Posted on

S3 Presigned URLs: Security Pitfalls You'll Regret

S3 Presigned URLs: Security Pitfalls You'll Regret

A presigned URL is a time-limited, signed link that grants temporary S3 access without sharing your credentials. The regrets come from treating it like a password: over-long expirations, plaintext HTTP, no network lock, and signing with permanent keys. The hardening below is about the URL model itself, so it applies to Amazon S3 and to any S3-compatible endpoint that speaks SigV4.

Key stats

Limit / control Value (Amazon S3) Why it matters
CLI / SDK max expiration 604,800 s (7 days) --expires-in caps here; the console caps at 12 hours
Default expiration 3,600 s (1 hour) Set it explicitly; never leave the default for sensitive data
Temporary-credential URLs Expire with the session STS AssumeRole defaults to 1 hour; EC2 profile ~6 hours
Enforce HTTPS aws:SecureTransport = false → Deny Blocks plaintext HTTP requests
Max signature age s3:signatureAge > 600000 ms → Deny Kills URLs older than 10 minutes

Every number above is from AWS's own presigned-URL and security-best-practices docs (links in Sources). We cite the docs, not blog lore.

What is an S3 presigned URL, really?

A presigned URL is a signed Amazon S3 link that wraps your security credentials and grants one specific operation — usually a GET to download or a PUT to upload — for a fixed window. Anyone holding the link can perform that operation, with no AWS account of their own. The signature is computed with your credentials under Signature Version 4 (SigV4), and AWS now uses SigV4 for every presigned URL, so the region must be set explicitly. You generate one with the AWS CLI:

aws s3 presign s3://amzn-s3-demo-bucket/mydoc.txt --expires-in 604800
Enter fullscreen mode Exit fullscreen mode

That command returns a URL carrying X-Amz-Algorithm, X-Amz-Credential, X-Amz-Expires, and X-Amz-Signature. The operation is limited to the permissions of whoever signed it. In other words, a presigned URL is a short-lived, single-purpose capability delegation — not an account, and not a password you can rotate after the fact.

How long can a presigned URL stay valid?

The ceiling depends on how you create it. From the AWS CLI or SDK, --expires-in accepts up to 604,800 seconds — exactly seven days — and defaults to 3,600 seconds (one hour) if you omit it. The S3 console is stricter: it lets you set between 1 minute and 12 hours, with no path to a 7-day link through the UI. For programmatic generation, 7 days is the hard stop for URLs signed with long-term IAM user credentials under SigV4.

A longer window is rarely the right answer. A presigned URL is a bearer token: the moment it leaks, whoever holds it has the access it grants until it expires. If you set 7 days "just in case," you have given a stolen link a week of life. Favor minutes for download links and single-use uploads. The only reason to push toward the maximum is a genuinely long, unattended transfer where the recipient polls over days — and even then, prefer a refresh loop over one giant timeout.

Why do presigned URLs expire earlier than you set?

Because the URL is only as alive as the credential that signed it. AWS states this plainly: a presigned URL expires when its underlying credential is revoked, deleted, or deactivated, even if you asked for a later expiration. This bites hardest with temporary credentials. A URL signed by an IAM role session dies when that session ends, regardless of the --expires-in you specified. The same is true for STS AssumeRole (default session of 1 hour) and EC2 instance-profile credentials (which rotate with a maximum validity of about 6 hours).

The practical consequence: if you generate a "7-day" URL inside a Lambda using a role, it may stop working in under an hour. For long-lived links, sign with a dedicated IAM user whose key you can rotate, or build a service that re-issues the URL on demand. Treat the configured expiration as an upper bound, never a guarantee, and log which principal signed each URL so you can revoke the source fast.

The biggest pitfall: presigned URLs are bearer tokens

AWS calls them out directly: presigned URLs are bearer tokens that grant access to whoever possesses them, so they must be protected appropriately. The trap is forgetting that. Teams paste them into Slack, commit them to repos, log them with the request, or stuff them into client-side JavaScript where anyone can read them. Once a URL is out, you cannot rotate it like a password — you can only wait for it to expire or revoke the signing credential entirely, which kills every URL that credential ever made.

Three habits cut most of the risk. Never log the full URL; log a hash or just the object key. Generate the URL at request time on the server, not once at build time. Scope it to the smallest action and shortest time the caller needs. If a URL must travel to a browser, hand it over through a short-lived endpoint and expire it aggressively. The bearer-token model means the link itself is the secret — handle it like one.

How do you keep presigned URLs short-lived by default?

You can enforce a maximum signature age at the bucket level, so even a URL generated with a long --expires-in gets rejected once its signature is too old. AWS documents this with the s3:signatureAge condition key. The example below denies any presigned request on a bucket if the signature is more than 10 minutes old (600,000 milliseconds):

{
    "Version":"2012-10-17",
    "Statement": [{
        "Sid": "Deny a presigned URL request if the signature is more than 10 min old",
        "Effect": "Deny",
        "Principal": {"AWS": "*"},
        "Action": "s3:*",
        "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/*",
        "Condition": {"NumericGreaterThan": {"s3:signatureAge": "600000"}}
    }]
}
Enter fullscreen mode Exit fullscreen mode

This is defense in depth: the URL's own expiration might be an hour, but the bucket refuses anything signed more than 10 minutes ago. Tune the millisecond value to your workflow. Pair it with short expirations at generation time, and a leaked link becomes useless fast even if someone sits on it. Note this works for SigV4-signed requests and complements — not replaces — a small --expires-in.

How do you force HTTPS on every presigned request?

Presigned URLs carry credentials in the query string, so a plaintext HTTP handoff is a credential leak waiting to happen. Amazon S3's security guidance is to allow only encrypted connections and deny the rest with the aws:SecureTransport condition. The documented example bucket policy denies every request that is not TLS:

{
    "Version":"2012-10-17",
    "Statement": [{
        "Sid": "RestrictToTLSRequestsOnly",
        "Action": "s3:*",
        "Effect": "Deny",
        "Resource": [
            "arn:aws:s3:::amzn-s3-demo-bucket",
            "arn:aws:s3:::amzn-s3-demo-bucket/*"
        ],
        "Condition": {"Bool": {"aws:SecureTransport": "false"}},
        "Principal": "*"
    }]
}
Enter fullscreen mode Exit fullscreen mode

With that in place, a presigned URL opened over HTTP returns Access Denied instead of serving the object. This applies to all requests to the bucket, presigned or not. One caveat: aws:SecureTransport is an AWS condition key; if you run an S3-compatible endpoint such as RustFS behind your own proxy, enforce TLS at the proxy or load balancer instead. The goal is the same — never let a signed URL traverse the wire in clear text.

How do you lock presigned URLs to a network?

By default a presigned URL works from anywhere on earth. If your recipients are internal, that is more reach than you want. AWS lets you constrain the network path with IAM condition keys. For requests hitting the public S3 endpoint, use aws:SourceIp; for traffic through a VPC endpoint, use aws:SourceVpc or aws:SourceVpce. The documented IAM statement denies all access unless it originates from a specific IP range:

{
    "Sid": "NetworkRestrictionForIAMPrincipal",
    "Effect": "Deny",
    "Action": "*",
    "Resource": "*",
    "Condition": {
        "NotIpAddressIfExists": {"aws:SourceIp": "IP-address-range"},
        "BoolIfExists": {"aws:ViaAWSService": "false"}
    }
}
Enter fullscreen mode Exit fullscreen mode

This restricts the credential itself, so any URL it signs is also bound to that network. Apply it at the IAM principal, the bucket policy, or both. For a VPC-only architecture, the aws:SourceVpc / aws:SourceVpce pair keeps URLs from ever leaving your private path. Combine with the short signature-age and HTTPS rules above and a leaked link is both geofenced and short-lived.

Which credential should sign the URL?

The signing credential decides how much damage a leak causes and how fast you can contain it. Long-term IAM user access keys are the worst choice for high-churn URL generation: they do not rotate, and revoking one means editing every system that depends on it. AWS's own best-practice guidance is to use IAM roles for applications and services rather than embedding long-term credentials, precisely because roles hand out temporary, scoped permissions.

For presigned URLs, that means generating them inside a role session — a Lambda execution role, an EC2 instance profile, or an STS AssumeRole call — and accepting that the URL lives only as long as that session. If you genuinely need a multi-day link, isolate a single IAM user with a tight policy (only s3:GetObject on the specific prefix) and rotate its key on a schedule. Never sign URLs with an admin or root credential. The principle: the credential behind the URL should be the easiest thing in the chain to revoke.

RustFS and other S3-compatible endpoints: same rules, your perimeter

The hardening above is about the URL model, not about Amazon S3 specifically. Any S3-compatible endpoint that speaks SigV4 — RustFS among them — accepts the same presigned URLs your AWS SDK produces, and the same bearer-token risks apply. The difference is the perimeter: AWS gives you aws:SecureTransport, aws:SourceIp, and s3:signatureAge as managed condition keys; a self-hosted store puts that enforcement on you, at the proxy, the network policy, or the bucket policy if the server implements it.

RustFS ships under Apache 2.0 with S3 Core, Versioning, Object Lock, Server-Side Encryption, and IAM/Policies marked available, and it starts with one command:

docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
Enter fullscreen mode Exit fullscreen mode

Default credentials are rustfsadmin / rustfsadmin — rotate them before exposure. If you run your own S3 surface, you own the HTTPS enforcement and network lock the cloud gives you for free, so bake them into the ingress. The security pitfalls are identical; only the place you close them changes.

FAQ

Are presigned URLs safe to send over email?

Yes, if they are short-lived and HTTPS-only. A presigned URL in email is a bearer token, so keep --expires-in to minutes for downloads and never send one that grants PUT to a writable prefix. Enforce aws:SecureTransport so the link fails on plaintext HTTP.

Can a presigned URL be revoked before it expires?

Not the URL itself. You revoke the signing credential — delete or deactivate the IAM user key, or end the role session — and every URL it created stops working immediately, even with time left. There is no per-URL kill switch.

Why is my presigned URL returning AccessDenied?

Most often the signing principal lacks the underlying permission (for example s3:GetObject), the bucket policy denies it, the region is wrong, or the request headers (such as Content-Type on a PUT) differ from generation. Clock drift also breaks SigV4 signatures.

What is the maximum expiration for a presigned URL?

Seven days (604,800 seconds) from the AWS CLI or SDK with IAM user credentials under SigV4. The console caps at 12 hours. URLs signed with temporary credentials expire with the session — STS AssumeRole defaults to 1 hour.

Do presigned URLs work with S3-compatible stores like RustFS?

Yes. Any endpoint that implements SigV4-signed S3 requests accepts the same presigned URLs. The security model is identical; you just supply the HTTPS and network restrictions yourself instead of relying on AWS condition keys.

Sources

Re-checked 2026-09-23.

Top comments (0)