Skip to content
AWS aws storage 6 min read

Presigned URLs

A presigned URL is a special web link that gives anyone who holds it temporary permission to read or write one specific object in Amazon S3 (Simple Storage Service, AWS’s object storage), even though the bucket itself stays completely private. You create the link in your backend using AWS credentials, hand it to a browser or a partner, and they use it directly against S3 for a limited time. This lets you share private files or accept uploads without ever making the bucket public and without handing out your AWS keys. This page explains how presigned URLs work, how to generate them, and the expiry gotcha that surprises almost everyone.

What a presigned URL actually is

When you “presign” a URL, the AWS SDK or CLI takes a normal S3 request (for example, GET this object) and signs it with a set of AWS credentials using SigV4 (Signature Version 4, AWS’s request-signing scheme). The signature, the expiry time, and the allowed action are all baked into the URL’s query string. S3 trusts the link because the math proves it was signed by someone who already had permission.

The key idea: a presigned URL carries borrowed permission. The person clicking the link does not need an AWS account. They temporarily act as the identity that signed the URL, but only for the one action and one object encoded in the link, and only until it expires.

PropertyWhat it means
Single objectThe link works for exactly one object key and one operation (GET or PUT)
Time-limitedIt stops working after the expiry you set
No AWS account neededThe recipient just opens the URL in a browser or sends an HTTP request
Inherits signer’s permissionsIt can only do what the signing credentials were already allowed to do

When to use this (and when not to)

Use a presigned GET URL to let a user download a private file they are entitled to — an invoice PDF, a paid video, a user’s own profile photo — without making the bucket public. Use a presigned PUT URL to let a browser or mobile app upload a file straight to S3, so the bytes never pass through (and never bloat) your own server.

Do not use presigned URLs for content that is genuinely public and high-traffic — serve that through a static website or a CDN like CloudFront instead. Also avoid them for long-lived “permanent” share links: they always expire, so if you need indefinite access, that is a sign you should rethink the design (for example, with CloudFront signed cookies).

Tip: Presigned URLs are the standard, safe way to do direct browser-to-S3 uploads. Your server signs a short-lived PUT URL, the browser uploads straight to S3, and your backend never touches the file data.

Generating a presigned GET URL

Using the AWS CLI

The CLI can presign a download link in one command. --expires-in is in seconds; below it is 1 hour.

aws s3 presign s3://devcraftly-assets/reports/q2-invoice.pdf \
  --expires-in 3600

Output:

https://devcraftly-assets.s3.us-east-1.amazonaws.com/reports/q2-invoice.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAEXAMPLE%2F20260615%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260615T120000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=4a7e...c9f1

Anyone can paste that link into a browser within the next hour and the file downloads — after that, S3 returns AccessDenied.

Using the SDK (Node.js / JavaScript)

In a real app you generate the URL in your backend so the signing credentials stay on the server.

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "us-east-1" });

const command = new GetObjectCommand({
  Bucket: "devcraftly-assets",
  Key: "reports/q2-invoice.pdf",
});

// expiresIn is in seconds (here: 15 minutes)
const url = await getSignedUrl(s3, command, { expiresIn: 900 });
console.log(url);

Doing it in the console

The S3 console does not generate presigned links directly. The closest one-click option is:

  1. Open the S3 console and click your bucket name.
  2. Click the object you want to share.
  3. Choose Object actions → Share with a presigned URL.
  4. Set the time interval (the maximum here is 12 hours, because the console uses your temporary session credentials), then choose Create presigned URL.
  5. Copy the link that appears.

For anything longer-lived or automated, use the CLI or SDK instead.

Generating a presigned PUT (upload) URL

To accept an upload, presign a PutObject request. The recipient then sends an HTTP PUT with the file as the body.

import { PutObjectCommand } from "@aws-sdk/client-s3";

const command = new PutObjectCommand({
  Bucket: "devcraftly-uploads",
  Key: "users/u-12345/avatar.png",
  ContentType: "image/png",
});

const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 600 });

The browser then uploads directly:

curl -X PUT \
  -H "Content-Type: image/png" \
  --upload-file ./avatar.png \
  "https://devcraftly-uploads.s3.us-east-1.amazonaws.com/users/u-12345/avatar.png?X-Amz-Signature=..."

Output:

HTTP/1.1 200 OK
x-amz-version-id: 3HL4kqCxf3vjVBH40Nrjfkd
ETag: "9b2cf535f27731c974343645a3985328"

Any header you sign (like Content-Type) must be sent exactly by the client, or S3 rejects the request with a signature mismatch.

The expiry gotcha that breaks things “overnight”

This is the part people debug for hours. A presigned URL inherits both the permissions and the lifetime of the credentials that signed it.

  • With long-lived IAM (Identity and Access Management) user access keys, SigV4 allows an expiry of up to 7 days (604800 seconds).
  • With temporary credentials — an IAM role on an EC2 instance, a Lambda function, AWS CloudShell, or anything using STS (Security Token Service) — the URL cannot outlive those temporary credentials, no matter what --expires-in you set.

So if your code runs on an EC2 instance and uses the instance role to sign a “7-day” URL, the URL silently dies the moment the instance’s temporary credentials rotate — often in an hour or two. Classic symptom: “the share link worked yesterday but everyone gets AccessDenied today.” The link did not change; the credentials behind it expired.

Gotcha: A presigned URL signed with temporary credentials (instance roles, Lambda, assumed roles) expires when those credentials expire — usually far sooner than your requested time. If you truly need multi-day links, sign with credentials that last that long, and remember the hard 7-day SigV4 ceiling.

Also remember: revoking the URL is not really possible. Once it is out, the only ways to cut access early are to delete the object, change the bucket policy, or revoke the signing principal’s permissions.

Best Practices

  • Keep the expiry as short as the use case allows — minutes for uploads, not days. A leaked link is valid for its whole lifetime.
  • Generate presigned URLs in your backend, never in client-side code, so the signing credentials are never exposed.
  • For URLs signed by EC2 instance roles or Lambda, assume they last only as long as the temporary credentials, and design around that rather than fighting it.
  • Restrict the signing principal with least privilege (only s3:GetObject or s3:PutObject on the needed prefix) so a leaked link can do nothing beyond its single object.
  • For uploads, sign the Content-Type (and a size condition via POST policies if needed) so clients cannot push arbitrary content.
  • Prefer CloudFront with signed URLs or cookies for high-traffic or public-style distribution; presigned S3 URLs are best for low-volume, per-user access.
  • There is no extra charge for creating presigned URLs — you pay only normal S3 request and data-transfer rates when the link is actually used.
Last updated June 15, 2026
Was this helpful?