S3 pre-signed URLs are treated as secure by design. The URL expires, has scope, and is bound to a specific operation. The problem is that every layer of the pattern creates attack surfaces that standard API security reviews miss.
You return a pre-signed URL to the client and consider the upload flow closed. Developers pass ExpiresIn=604800, thinking one week is convenient. The URL carries the signing credential in plain text as query parameters, appearing verbatim in CloudFront access logs. None of these facts appear in the SDK documentation your team consulted.
A Pre-Signed URL Is a Serialized Credential, Not a Safe Temporary Link
An S3 pre-signed URL embeds signing credentials directly as query parameters. The SigV4 parameters include X-Amz-Algorithm, X-Amz-Credential (AccessKeyId and scope), X-Amz-Date, X-Amz-Expires, X-Amz-SignedHeaders, and X-Amz-Signature. STS sessions add X-Amz-Security-Token, making the session token visible in the URL as well.
AWS documentation classifies pre-signed URLs as bearer tokens that grant access to any entity possessing them. Any entity with the URL executes the signed operation without additional authentication. Every system that logs or stores the full URL is storing live credentials until expiry.
The same mechanism that makes the URL easy to use makes its compromise silent. There is no second factor, no IP validation by default, no CloudTrail event when the URL is created. Possession of the URL is sufficient.
Long Pre-Signed URL Expiry Is a Convenience Choice, Not a Security Control
The AWS SDK allows pre-signed URLs with validity of up to 604800 seconds (7 days) for IAM user credentials. Developers pass ExpiresIn=604800 explicitly because one week feels convenient for sharing files. The URLs circulate through email threads, API response caches, and CDN layers long after the original use case ends.
STS credentials behave asymmetrically. Role sessions default to 1 hour and max out at 12 hours. EC2 instance profile credentials rotate every 1 to 6 hours. A developer testing locally with permanent IAM credentials does not observe this behavior and assumes production URLs carry the same deadline.
WithSecure documented a concrete scenario. On a compromised EC2 instance, an attacker generates pre-signed URLs that remain valid for hours after incident response terminates the instance. Slack and Teams link previews trigger a GET on the URL before the user clicks. API responses without proper Cache-Control distribute the valid URL to multiple consumers. That seven-day window is not theoretical: it is real exploitation time.
Path-Based Signing Without User Binding Creates Cross-User IDOR
APIs that accept the object key as a request parameter and generate pre-signed URLs without server-side ownership validation create cross-user IDOR. Any authenticated user obtains a valid URL for another user's object by modifying the key.
ivision Research documented this pattern in a client engagement. The backend accepted the object key as a request parameter and generated the signed URL without verifying ownership. Changing pdfs/123/receipt.pdf to pdfs/124/receipt.pdf returned a valid URL for another user's document. The impact is unauthorized read access to arbitrary objects across the entire user base.
A private bug bounty program in 2023 paid $20,000 for this pattern. The pre-signed URL scope was not bound to the authenticated user's identity, leaking all user attachments across the application. The fix is constructing the object key server-side as <user_id>/<uuid>/<filename> and never accepting the key as a client parameter.
Pre-Signed PUT URLs Do Not Enforce Content-Type
Pre-signed PUT URLs carry no Content-Type restriction enforceable by S3. File type validation happens at URL generation time on the backend, not at upload time. An attacker with a valid PUT URL uploads arbitrary content to the signed object key.
Only pre-signed POST policies support Content-Type conditions enforced by S3 at upload time. Detectify documented a bypass: a POST policy with a starts-with condition on content-type was defeated by appending image/jpegz;text/html to the value. The content was served as text/html, producing XSS. The anti-pattern is validating the MIME type before generating the URL and assuming S3 maintains that restriction at upload time. S3 does not.
Any attacker with a valid PUT URL can upload an SVG with embedded JavaScript, an executable, or another file to the signed key. If the bucket serves content via CDN without edge Content-Type validation, the impact ranges from malware storage to persistent XSS. Every user fetching the object is exposed.
Pre-Signed URLs in Logs Are Credentials in Logs
S3 access logs record the full request URI, including all query parameters, by default. The CloudFront cs-uri-query field contains the full query string including X-Amz-Signature and X-Amz-Credential. Browser history records full URLs. Pre-signed download links appear as clickable entries valid until expiry.
The most overlooked aspect is forensic blindness. WithSecure documented that no CloudTrail event is generated when a pre-signed URL is created. Usage events require S3 Data Events explicitly enabled, a setting that is off by default. You may not know a URL was generated or that it was used by an unauthorized third party.
The MAGO Intel tool (intel.mago.team) scans API surfaces for pre-signed URL generation endpoints and flags responses containing X-Amz-Signature parameters in redirect or response bodies. AWS Prescriptive Guidance notes that signature-based solutions transmitting the signature as a header rather than a query string avoid log exposure entirely. That approach is documented as more secure but rarely adopted in practice.
Link Preview Services Consume the URL Before the Recipient Does
Slack, Teams, and similar platforms fetch a preview of any URL shared in a channel. That GET request uses the pre-signed URL. For single-use URLs, the request consumes the token. For multi-use URLs, the platform IP appears in S3 access logs rather than the intended recipient's address. The token in the URL is now in Slack's access logs and in the platform's caching infrastructure for the duration of the expiry window. Sharing a pre-signed S3 URL in any chat tool is equivalent to posting the credential publicly to everyone with access to that channel's audit log.
Five Controls That Actually Reduce the Attack Surface
Pre-signed URLs remain the right choice when the alternative is a proxy API that adds latency, cost, and a single failure point for large transfers. Correct configuration is the goal: short expiry matched to the actual transfer window, scope-limited IAM credentials, and no URL in any log aggregation path.
The SDK Expires parameter is not the only enforcement point. Bucket policies with the s3:signatureAge condition apply a maximum age limit at the S3 layer, regardless of what the application configures. A policy denying requests where s3:signatureAge exceeds 600000ms holds even if the backend generates longer-lived URLs by mistake.
Object key binding is non-negotiable for endpoints serving per-user data. Constructing the key server-side as <user_id>/<uuid>/<filename> and never accepting the key as a request parameter eliminates IDOR by redesign. For uploads, pre-signed POST with content-type and content-length-range policies ensures conditions are enforced by S3 at upload time. Pre-signed PUT provides no such guarantee.
In log pipelines, treat pre-signed URL query strings as secrets at the same level as Authorization headers. Redact the X-Amz-Signature value before ingestion into any log destination. Enable S3 Data Events in CloudTrail for externally-facing buckets. A bucket policy restricting aws:SourceIp to known IP ranges limits URL reuse when S3 Data Events are unavailable.
Before your next API security review, grep your application logs for X-Amz-Signature. If it appears, you are storing credentials in logs. That is the first control to fix.
Top comments (0)