Skip to content

Presigned URLs & public access

There are two ways to give someone access to an object without handing them an access key: presigned URLs for time-limited, per-object access, and public buckets for assets you want anyone to read forever.

A presigned URL is a regular HTTPS URL with the request signature baked in as query parameters. Whoever holds the URL can perform exactly one operation on exactly one object until the URL expires.

Typical uses:

  • Let a browser upload directly to the bucket — no need to stream bytes through your backend.
  • Hand out a time-limited download link for a private file (an invoice, a report, a paid asset).
  • Generate a one-time link in an email so the recipient doesn’t need an account.

URLs are short-lived. Default lifetime is 10 minutes, max 24 hours — pick the smallest window that fits your use case.

In the bucket’s Files tab, click the Share link icon on any file. The dashboard opens a modal with a copy-pasteable presigned download URL. If the bucket is public the modal switches to showing the permanent public URL instead.

# Generate a presigned download URL — valid for 1 hour
download_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "user-uploads", "Key": "reports/2026-05.pdf"},
ExpiresIn=3600,
)
# Generate a presigned upload URL — valid for 10 minutes
upload_url = s3.generate_presigned_url(
"put_object",
Params={
"Bucket": "user-uploads",
"Key": "avatars/u123.png",
"ContentType": "image/png",
},
ExpiresIn=600,
)
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
// Download URL — valid for 1 hour
const downloadUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: 'user-uploads', Key: 'reports/2026-05.pdf' }),
{ expiresIn: 3600 },
);
// Upload URL — valid for 10 minutes
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: 'user-uploads',
Key: 'avatars/u123.png',
ContentType: 'image/png',
}),
{ expiresIn: 600 },
);

The recommended flow for a SPA that lets users upload files:

  1. Browser asks your backend for a presigned PUT URL for a key like uploads/<user_id>/<uuid>.png.
  2. Backend uses an S3 SDK with the access key (kept on the server!) to generate the signed URL and returns it.
  3. Browser does a single fetch(url, { method: 'PUT', body: file }) to upload directly to Runsite — bytes never flow through your backend.
// browser
const res = await fetch('/api/upload-url', { method: 'POST', body: JSON.stringify({ name: file.name }) });
const { url } = await res.json();
await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});

Files larger than ~5 MB benefit from multipart uploads — they upload in parallel and resume cleanly on network errors. Every official S3 SDK does this transparently when you use the high-level helpers (upload_file / upload_fileobj in boto3, Upload in @aws-sdk/lib-storage, s3 cp and s3 sync in the AWS CLI).

For browser-side multipart, generate a separate presigned URL for each part. The flow is:

  1. Backend calls CreateMultipartUpload and returns the UploadId.
  2. Backend generates a presigned UploadPart URL for each part.
  3. Browser uploads the parts in parallel.
  4. Backend calls CompleteMultipartUpload with the resulting ETags.

When you flip a bucket to Public access in Settings, every object in it becomes readable anonymously over HTTPS:

https://s3.runsite.app/<bucket>/<key>
https://<bucket>.s3.runsite.app/<key>

That’s it — drop these URLs into <img> tags, CSS, <a href>, marketing pages, RSS feeds, anywhere.

When to use public buckets:

  • ✅ Marketing images, hero photos, downloadable PDFs, font files, public datasets.
  • ✅ Static assets for a Web Service or Static Site that don’t need access control.
  • ❌ User-private data — invoices, receipts, anything per-user. Use a private bucket and presigned URLs instead.

Switching a bucket from private to public exposes every existing object. Audit the contents first.

If your browser uploads (presigned PUT, multipart) fail with a CORS error, the bucket needs a CORS rule that permits your origin and the request method. Configure it from the bucket’s Settings tab — the configuration shape matches the standard S3 CORSConfiguration:

{
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
}

Server-to-server traffic (your backend, CI, scripts) does not need CORS — only browser code does.

Rule of thumb

Use public buckets for things you would put on a CDN. Use presigned URLs for everything else — they keep the secret on the server, expire automatically and can be issued per-user.