Article

S3 presigned URLs: what they are, how they work, expiry limits and code examples

What an S3 presigned URL is, what each X-Amz parameter means, the 7-day expiry limit, upload and download code (CLI, boto3, JS) and common error fixes.

An S3 presigned URL is a temporary link to one object in a private bucket. It carries a signature in its query string, so whoever holds it can download (or upload) that object without AWS credentials until the link expires, and the bucket never becomes public. You create it with your own keys, in your code or from the AWS CLI, and it can last from 1 second up to 7 days. This guide covers how it works, what every X-Amz-* parameter means, how long it can last, code for downloads and uploads and the errors you are most likely to hit. Limits are taken from the AWS and Cloudflare documentation as of October 2026.

S3 presigned URLs at a glance

QuestionAnswer
What does it grant?One operation (GET, PUT, HEAD, DELETE…) on one object
Maximum expiry7 days (604,800 seconds) with SigV4 and IAM user keys
Expiry with temporary keysEnds when the role session or STS credentials end, whichever comes first
S3 console range1 minute to 12 hours
Default expiry3,600 s in the AWS CLI and boto3, 900 s in the JavaScript v3 presigner
Can it be used several times?Yes, as often as anyone likes until it expires
Can one URL be revoked?No, only by revoking the signing credentials or denying it in a policy
Does creating one call S3?No, the signature is computed locally

How a presigned URL works

  1. Your code (or the CLI) takes the bucket, the object key, the HTTP method and an expiry time.
  2. The SDK signs those values with your secret access key using AWS Signature Version 4. Nothing is sent to S3 at this point.
  3. You give the resulting URL to someone: a browser, a colleague, a mobile app.
  4. When they use it, S3 recomputes the signature with the secret that belongs to the access key in the URL. If it matches, the URL has not expired and the signer is allowed to perform that operation, the request goes through.

The URL carries the permissions of whoever signed it. AWS is explicit about this: if the IAM user or role that generated the URL cannot read the object, the URL returns 403 however valid the signature is.

What the X-Amz parameters mean

A SigV4 presigned URL looks like this (line breaks added):

https://my-bucket.s3.eu-west-1.amazonaws.com/reports/q3.pdf
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261017%2Feu-west-1%2Fs3%2Faws4_request
  &X-Amz-Date=20261017T093000Z
  &X-Amz-Expires=3600
  &X-Amz-SignedHeaders=host
  &X-Amz-Signature=5f2c...e91a
ParameterMeaning
X-Amz-AlgorithmThe signing algorithm: AWS4-HMAC-SHA256 for SigV4
X-Amz-CredentialAccess key ID plus scope: <key-id>/<YYYYMMDD>/<region>/s3/aws4_request
X-Amz-DateWhen the URL was signed, in UTC, yyyyMMddTHHmmssZ
X-Amz-ExpiresHow many seconds the URL is valid after X-Amz-Date, from 1 to 604800
X-Amz-SignedHeadersHeaders covered by the signature, always at least host
X-Amz-SignatureThe signature, 64 hexadecimal characters
X-Amz-Security-TokenOnly present when the URL was signed with temporary credentials (STS, roles, EC2, ECS, SSO)

Change any signed part (the key, the method, the expiry or a signed header) and the signature no longer matches. Note also what the URL reveals in plain text: the bucket name, the full object key and your access key ID. The secret key is never in the URL.

How long a presigned URL lasts

The limit depends on the credentials that sign it:

Signed withMaximum validity
IAM user access keys (SigV4)7 days, because the SigV4 signing key itself is valid for 7 days
IAM role session (STS AssumeRole)Until the session ends, 1 hour by default
EC2 instance profileUntil the role credentials rotate, at most about 6 hours
ECS task roleUntil the task credentials rotate, typically every 1 to 6 hours
Any credentials revoked or deletedStops immediately, whatever X-Amz-Expires says
S3 consoleYou choose between 1 minute and 12 hours

This explains the most common surprise: you ask for 7 days from a Lambda function or an EC2 instance and the link dies after an hour or a few hours. The URL is only as long-lived as the credentials behind it. If you need long links, sign with a dedicated IAM user that has read access to that bucket only.

S3 checks the expiry when a request starts. A large download that begins one second before the deadline finishes normally, but a retry after the deadline fails with AccessDenied and the message “Request has expired”. Asking for more than 604,800 seconds is rejected outright.

How to generate a presigned URL

AWS CLI

aws s3 presign s3://my-bucket/reports/q3.pdf --expires-in 3600

The command signs a download (GET) URL. The default is 3,600 seconds and the maximum 604,800. Recent CLI versions always use SigV4, so make sure a region is configured. For upload URLs you need an SDK.

Python (boto3)

import boto3
from botocore.config import Config

s3 = boto3.client('s3', region_name='eu-west-1', config=Config(signature_version='s3v4'))

url = s3.generate_presigned_url(
    ClientMethod='get_object',
    Params={
        'Bucket': 'my-bucket',
        'Key': 'reports/q3.pdf',
        'ResponseContentDisposition': 'attachment; filename="q3.pdf"',
    },
    ExpiresIn=3600,
)

Set signature_version='s3v4' explicitly. The boto3 reference warns that, for backwards compatibility, it can otherwise sign with the older Signature Version 2 in regions that still accept it, which silently drops some parameters and headers such as Range. ResponseContentDisposition is optional: it makes the browser save the file with a clean name instead of opening it inline.

JavaScript (AWS SDK v3)

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

const client = new S3Client({ region: 'eu-west-1' })

const url = await getSignedUrl(
  client,
  new GetObjectCommand({ Bucket: 'my-bucket', Key: 'reports/q3.pdf' }),
  { expiresIn: 3600 }
)

If you leave out expiresIn, the JavaScript presigner uses 900 seconds (15 minutes).

Presigned upload URLs (PUT)

The same idea works in the other direction. You sign a PutObject for one key and whoever has the URL can write that object. The file goes straight from their device to the bucket and your server never handles the bytes. If an object with that key already exists, it is replaced.

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

const client = new S3Client({ region: 'eu-west-1' })

const url = await getSignedUrl(
  client,
  new PutObjectCommand({
    Bucket: 'my-bucket',
    Key: 'incoming/brief.pdf',
    ContentType: 'application/pdf',
  }),
  { expiresIn: 900, signableHeaders: new Set(['content-type']) }
)

await fetch(url, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/pdf' },
  body: file,
})

Adding content-type to signableHeaders binds the URL to that type, so an upload with a different Content-Type is rejected. In boto3 the equivalent is generate_presigned_url('put_object', Params={'Bucket': ..., 'Key': ..., 'ContentType': 'application/pdf'}), and the client must send the same header.

Three things to check before uploading from a browser:

  • CORS. The bucket needs a CORS rule that allows your origin and the PUT method. For multipart uploads it must also expose the ETag header, because the browser has to read it from each part.
  • Fixed key. Generate a unique key on the server (for example with a UUID prefix) so a visitor cannot overwrite someone else’s file.
  • Checksums. Current AWS SDKs add a CRC checksum to uploads by default. If presigned uploads fail against an S3-compatible provider with signature or checksum errors, set requestChecksumCalculation: 'WHEN_REQUIRED' on the client.

Presigned POST is a separate mechanism for plain HTML forms, with a policy that can limit size and content type. Not every S3-compatible provider supports it (R2 does not), so presigned PUT is the more portable choice.

Large files: presigned multipart uploads

A single PUT to S3 is capped at 5 GB, and AWS suggests multipart from about 100 MB. Multipart splits the file into up to 10,000 parts, each from 5 MiB to 5 GiB except the last, which upload independently, in parallel and with retries per part. Presigning works per part: the server starts the upload, signs one UploadPart URL per part, the browser sends each chunk and reports back its ETag, and the server completes the upload.

import {
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
} from '@aws-sdk/client-s3'

const { UploadId } = await client.send(
  new CreateMultipartUploadCommand({ Bucket: 'my-bucket', Key: 'video/raw.mov' })
)

const partUrl = await getSignedUrl(
  client,
  new UploadPartCommand({ Bucket: 'my-bucket', Key: 'video/raw.mov', UploadId, PartNumber: 1 }),
  { expiresIn: 3600 }
)

await client.send(
  new CompleteMultipartUploadCommand({
    Bucket: 'my-bucket',
    Key: 'video/raw.mov',
    UploadId,
    MultipartUpload: { Parts: [{ PartNumber: 1, ETag: etagFromPartResponse }] },
  })
)

Add a lifecycle rule that aborts incomplete multipart uploads after a few days, or abandoned parts keep taking up billable storage.

Presigned URLs on R2, B2, Spaces and Wasabi

Providers that implement the S3 API with SigV4 accept the same code: you only change the endpoint, the region and the keys. Details differ, so check the provider documentation:

  • Cloudflare R2 supports presigned GET, HEAD, PUT and DELETE, with an expiry from 1 second to 7 days. It does not support presigned POST, and presigned URLs only work on the S3 API domain (<ACCOUNT_ID>.r2.cloudflarestorage.com), not on custom domains. Use region: 'auto'.
  • Backblaze B2 states that its S3-compatible API supports presigned URLs generated with the AWS CLI or SDKs.
  • DigitalOcean Spaces and Wasabi work with the same SDK calls pointed at their endpoints.

For prices on each provider, see the comparison of S3, R2, Spaces, Wasabi and B2.

Common presigned URL errors and how to fix them

ErrorUsual causeFix
SignatureDoesNotMatchMethod, headers or query differ from what was signed; clock out of syncSend the same Content-Type and headers; sync the clock with NTP
AccessDenied “Request has expired”The URL is past X-Amz-Date + X-Amz-ExpiresGenerate a new URL; shorten the gap between signing and use
URL expires earlier than requestedSigned with temporary credentials (role, EC2, ECS, Lambda)Sign with credentials that live as long as the link must
ExpiredTokenThe STS session behind X-Amz-Security-Token has endedRefresh credentials before signing
403 AccessDenied on a fresh URLThe signer lacks s3:GetObject or s3:PutObject, or a policy denies itGrant the permission to the signing principal; check bucket policies
AuthorizationQueryParametersErrorExpiry above 604,800 seconds or a malformed credential scopeKeep X-Amz-Expires at 7 days or less; sign in the bucket’s region
Browser blocked by CORSNo CORS rule for your origin and method on the bucketAdd a CORS rule with your origin, GET/PUT and exposed ETag
AccessDenied with HeadersNotSignedRange was signed but If-Range was sent unsignedAdd If-Range to the signed headers

Corporate proxies that rewrite headers or query strings also break signatures. If a URL works from one network and not another, test without the proxy.

Security best practices

AWS calls presigned URLs bearer tokens and recommends protecting them. In practice:

  • Keep them short. Minutes for uploads and sensitive files, hours for ordinary downloads. Generate a fresh one when someone needs the file again.
  • Sign with a narrow identity. A dedicated IAM user or role that can only read (or only write) the prefix in question limits the damage if a link leaks.
  • Add guardrails in the bucket policy. The s3:signatureAge condition rejects signatures older than a set age, and aws:SourceIp restricts which networks can use them.
  • Do not log them. Full presigned URLs in application logs, analytics or chat history are working credentials until they expire.
  • Plan for what they lack. There is no password, no download count and no per-link revocation, so put an application in front when you need those.

For the sharing side in depth, with options that add those controls, see how to share S3 files without making the bucket public.

How Nubbo uses presigned URLs

Nubbo is built on this pattern. When you upload in the explorer, the server signs a PUT (or, from 100 MB, a multipart upload with three parts in parallel) and your browser sends the bytes directly to your bucket. Single-file downloads use presigned GET URLs valid for one hour. Your access keys are stored encrypted with AES-256-GCM and are only used on the server to sign.

Share links add what raw presigned URLs lack: an optional password, expiry date and download limit that Nubbo checks before it issues a short-lived URL, and you can deactivate a link at any time. Multi-file ZIP downloads are assembled in your browser, which downloads each file straight from your bucket: Nubbo never sees the content. One honest exception: thumbnails are written to your own bucket under .nubbo-thumbs/. It works the same on Amazon S3, Cloudflare R2, DigitalOcean Spaces, Wasabi, Backblaze B2 and public S3-compatible endpoints.

Frequently asked questions

What is a presigned URL in S3?

It is a normal HTTPS link to one object in a private bucket with a signature added to the query string. Whoever has the link can perform the single operation it was signed for, usually a download (GET) or an upload (PUT), until it expires. The bucket stays private and the person using the link needs no AWS credentials.

What is the maximum expiration of an S3 presigned URL?

Seven days (604,800 seconds) when you sign with Signature Version 4 and long-term IAM user keys. A URL signed with temporary credentials, such as an assumed role or an EC2 instance profile, stops working when those credentials expire, even if you asked for longer. In the S3 console you can choose between 1 minute and 12 hours.

What do X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-SignedHeaders and X-Amz-Signature mean?

They are the SigV4 authentication parameters. Algorithm is the signing method (AWS4-HMAC-SHA256), Credential is your access key ID plus the date, region and service scope, Date is when the URL was signed, Expires is how many seconds it stays valid, SignedHeaders lists the headers covered by the signature and Signature is the signature itself. X-Amz-Security-Token appears only when the URL was signed with temporary credentials.

Can I revoke an S3 presigned URL before it expires?

Not one by one. A presigned URL stops working early only when the credentials that signed it are revoked, deleted or deactivated, or when a bucket policy denies the request. If you need to cancel individual links, put an application in front that hands out short-lived URLs only after its own checks.

Are S3 presigned URLs secure?

The signature cannot be forged or altered, and the bucket stays private. But AWS describes presigned URLs as bearer tokens: anyone who gets the link can use it, as many times as they like, until it expires. Keep expiry times short, use HTTPS and avoid posting the links where they can be copied.

Do presigned URLs work with Cloudflare R2, Backblaze B2 or Wasabi?

Providers that implement the S3 API with Signature Version 4 generally support them, and you use the same SDK calls with the provider endpoint. Cloudflare R2, for example, supports presigned GET, HEAD, PUT and DELETE with an expiry from 1 second to 7 days, but not POST form uploads and not custom domains.

Why does my presigned URL return SignatureDoesNotMatch?

AWS lists three common causes: a computer clock that is out of sync, a proxy that changes headers or the query string, and a request whose method, headers or parameters differ from the ones that were signed. On uploads the usual culprit is sending a different Content-Type from the one used when the URL was generated.

Sources

Prices and limits come from the official pages below. Providers change them, so check before you commit.