S3 Presigned URLs: Security Pitfalls You'll Regret
DEV Community

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 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"}} }] } 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": "" }] } 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"} } } 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:

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.