[{"data":1,"prerenderedAt":15},["ShallowReactive",2],{"$f0OeIW7ThTct8GhOV1MN1QoTTWqJz1_rPGsd9GXGBSPs":3},{"slug":4,"title":5,"description":6,"date":7,"tags":8,"draft":12,"readingMinutes":13,"html":14},"s3-presigned-url","S3 Presigned URLs: Uploads, Expiry and the Errors You'll Hit","How S3 presigned URLs work in 2026: the real expiry limits (7 days, 12 hours, or whenever your role credentials die), browser uploads with Node, and the 403s that come with them.","2026-10-23",[9,10,11],"aws","s3","node",false,5,"\u003Cp>An S3 presigned URL is a normal S3 URL with a signature in the query string. Whoever holds it can do one thing, like download one object with \u003Ccode>GET\u003C/code> or upload one object with \u003Ccode>PUT\u003C/code>, until it expires, without having any AWS credentials of their own. It&#39;s signed with the credentials of whoever generated it, which is the detail behind most of the bugs people hit with them.\u003C/p>\n\u003Cp>S3 is in almost every project I build, and presigned uploads are how I let a browser or a mobile app push files straight to a bucket without the bytes passing through my server. Everything below was checked against the \u003Ca href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html\">AWS presigned URL docs\u003C/a> on October 23, 2026.\u003C/p>\n\u003Ch2>How long a presigned URL actually lasts\u003C/h2>\n\u003Cp>The number you pass as \u003Ccode>expiresIn\u003C/code> is a maximum, not a promise. AWS&#39;s own wording: a presigned URL expires at its configured time &quot;or when its associated credentials expire, whichever occurs first.&quot;\u003C/p>\n\u003Cp>So the real limit depends on what signed it:\u003C/p>\n\u003Cimg src=\"/blog/s3-presigned-url/s3-presigned-url-expiry-limits.webp\" alt=\"Bar chart of real presigned URL lifetimes: IAM user keys up to 7 days, S3 console up to 12 hours, EC2 instance role about 6 hours, AssumeRole default 1 hour\" width=\"1600\" height=\"900\" loading=\"lazy\">\u003Cul>\n\u003Cli>\u003Cstrong>IAM user access keys\u003C/strong> with Signature Version 4: up to 7 days (604,800 seconds). Ask for more and the SDK refuses to sign.\u003C/li>\n\u003Cli>\u003Cstrong>The S3 console\u003C/strong> (Object actions, Share with a presigned URL): anything from 1 minute to 12 hours.\u003C/li>\n\u003Cli>\u003Cstrong>An EC2 instance profile\u003C/strong>: the role credentials last around 6 hours, so a &quot;7 day&quot; URL signed on that box can die the same afternoon.\u003C/li>\n\u003Cli>\u003Cstrong>STS AssumeRole\u003C/strong>: the session is 1 hour by default, and the URL goes with it. ECS task credentials rotate every 1 to 6 hours.\u003C/li>\n\u003C/ul>\n\u003Cp>This is the classic one. Locally you sign with your own access keys and the 24-hour links you email to users work fine. You deploy to Lambda or ECS, the code signs with the role&#39;s temporary credentials, and support tickets start arriving with \u003Ccode>ExpiredToken\u003C/code> in the XML. Nothing changed in your code. If links have to live for days, sign them with a dedicated IAM user that has \u003Ccode>s3:GetObject\u003C/code> on one prefix and nothing else, or stop handing out long links and generate a fresh one each time the user clicks.\u003C/p>\n\u003Cp>One nice detail: S3 checks the expiry when the request starts. A big download that begins one second before expiry finishes. A resumed download after expiry fails.\u003C/p>\n\u003Ch2>A browser upload in Node, the short version\u003C/h2>\n\u003Cp>With the AWS SDK for JavaScript v3 it&#39;s two packages, \u003Ccode>@aws-sdk/client-s3\u003C/code> and \u003Ccode>@aws-sdk/s3-request-presigner\u003C/code>:\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-js\">import { S3Client, PutObjectCommand } from &#39;@aws-sdk/client-s3&#39;\nimport { getSignedUrl } from &#39;@aws-sdk/s3-request-presigner&#39;\nimport { randomUUID } from &#39;node:crypto&#39;\n\nconst s3 = new S3Client({ region: &#39;eu-west-1&#39; })\n\nexport async function uploadUrl(userId, contentType) {\n  const key = `uploads/${userId}/${randomUUID()}`\n  const url = await getSignedUrl(\n    s3,\n    new PutObjectCommand({ Bucket: &#39;my-app-uploads&#39;, Key: key, ContentType: contentType }),\n    { expiresIn: 300 }\n  )\n  return { url, key }\n}\n\u003C/code>\u003C/pre>\n\u003Cp>The browser then sends \u003Ccode>fetch(url, { method: &#39;PUT&#39;, body: file, headers: { &#39;Content-Type&#39;: file.type } })\u003C/code>, and you save \u003Ccode>key\u003C/code> in your database once it returns 200.\u003C/p>\n\u003Cp>Two things I always do here. The key is generated on the server with a UUID, never taken from the file name the user picked, because if an object with that key already exists, S3 simply replaces it. And five minutes is plenty: the URL is only needed for the one upload that starts right after the page asks for it.\u003C/p>\n\u003Cp>The bucket also needs a CORS rule or the browser blocks the \u003Ccode>PUT\u003C/code> before S3 ever sees it. Allow \u003Ccode>PUT\u003C/code> from your own origin, allow the \u003Ccode>Content-Type\u003C/code> header, and expose \u003Ccode>ETag\u003C/code> if you&#39;ll do multipart later. The \u003Ca href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html\">S3 CORS docs\u003C/a> have the JSON shape.\u003C/p>\n\u003Ch2>The errors, and what they usually mean\u003C/h2>\n\u003Cp>\u003Cstrong>\u003Ccode>SignatureDoesNotMatch\u003C/code>.\u003C/strong> Nine times out of ten the request isn&#39;t the one you signed. The usual culprit is \u003Ccode>Content-Type\u003C/code>: you signed \u003Ccode>image/png\u003C/code> and the browser sent \u003Ccode>image/jpeg\u003C/code>, or you signed \u003Ccode>application/octet-stream\u003C/code> and some upload library added its own header. Method, headers and query string all have to match what was signed. The other causes AWS lists are clock drift on the signing machine and corporate proxies that rewrite headers.\u003C/p>\n\u003Cp>\u003Cstrong>\u003Ccode>AccessDenied\u003C/code> (403).\u003C/strong> The URL can only do what its signer can do. If the Lambda role lacks \u003Ccode>s3:PutObject\u003C/code> on that prefix, the URL is born broken, and you won&#39;t find out until someone uses it. A bucket policy with an explicit deny wins too.\u003C/p>\n\u003Cp>\u003Cstrong>\u003Ccode>ExpiredToken\u003C/code>.\u003C/strong> The temporary credentials behind the URL are gone (see the expiry section above).\u003C/p>\n\u003Cp>\u003Cstrong>\u003Ccode>AccessDenied\u003C/code> with \u003Ccode>HeadersNotSigned: if-range\u003C/code>.\u003C/strong> Rare, but it shows up with video players doing range requests: if \u003Ccode>Range\u003C/code> is signed, S3 wants \u003Ccode>If-Range\u003C/code> signed as well.\u003C/p>\n\u003Ch2>You can&#39;t cap the file size with a PUT URL\u003C/h2>\n\u003Cp>This one surprises people. A presigned \u003Ccode>PUT\u003C/code> lets the holder upload anything up to S3&#39;s 5 GB single-request limit, to that key, as many times as they like until it expires. You can&#39;t say &quot;max 10 MB&quot; in it.\u003C/p>\n\u003Cp>If size matters (avatars, user content on a free plan), use a presigned POST instead. \u003Ccode>createPresignedPost\u003C/code> from \u003Ccode>@aws-sdk/s3-presigned-post\u003C/code> takes a policy with conditions, and \u003Ccode>[&#39;content-length-range&#39;, 0, 10485760]\u003C/code> makes S3 itself reject anything over 10 MB. It&#39;s a form upload with fields instead of a raw body, so the browser code is slightly uglier, and I think it&#39;s worth it. Checking \u003Ccode>file.size\u003C/code> in JavaScript is a UX nicety, not a limit.\u003C/p>\n\u003Cp>For files over a few hundred MB, I switch to multipart: the server starts the upload, presigns one URL per part (\u003Ccode>UploadPartCommand\u003C/code>), the client sends parts in parallel, and the server completes it. More moving parts, but a dropped connection costs one part instead of the whole file.\u003C/p>\n\u003Ch2>Locking them down a bit more\u003C/h2>\n\u003Cp>Presigned URLs are bearer tokens. Anyone who gets the link gets the access, so they shouldn&#39;t show up in logs, analytics tools or error trackers that record full URLs. Two bucket-level controls help:\u003C/p>\n\u003Cul>\n\u003Cli>The \u003Ccode>s3:signatureAge\u003C/code> condition key in a bucket policy denies any presigned request whose signature is older than a limit you set (AWS&#39;s example uses 10 minutes), no matter what \u003Ccode>expiresIn\u003C/code> the code asked for. Good backstop for the day someone writes \u003Ccode>expiresIn: 604800\u003C/code> &quot;just for testing&quot;.\u003C/li>\n\u003Cli>\u003Ccode>aws:SourceIp\u003C/code> or \u003Ccode>aws:SourceVpce\u003C/code> conditions restrict where requests may come from, which is handy for internal tools.\u003C/li>\n\u003C/ul>\n\u003Cp>For downloads served to lots of users, I usually don&#39;t hand out S3 presigned URLs at all. I put CloudFront in front of the bucket and use \u003Ca href=\"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html\">CloudFront signed URLs\u003C/a> or signed cookies, so the files are cached at the edge and I&#39;m not paying S3 request and transfer prices on every view. That&#39;s also where the S3 line on the bill stops growing so fast. If yours is already too big, that&#39;s exactly what my free bill review at \u003Ca href=\"https://slashcloudbill.com/\">slashcloudbill.com\u003C/a> looks at.\u003C/p>\n\u003Cp>If you&#39;re adding uploads to an app and want a second pair of eyes on the bucket setup, \u003Ca href=\"https://calendly.com/director-norsky/meeting\">book a free call\u003C/a>.\u003C/p>\n",1792714869616]