S3 Presigned URLs Temporary File Access Without Making Your Bucket Public
AWS S3 presigned URLs let you hand users temporary, signed access to private objects — no public bucket required. Here's how the HMAC-SHA256 signing actually works, how to wire up a direct client-to-S3 upload flow, and what to watch out for with expiry.
Your bucket is private. Good. Now you need to serve a PDF invoice to the user who uploaded it three weeks ago. You’re weighing three options: make the bucket public (please don’t), proxy every download through your backend (expensive, slow), or use presigned URLs.
Presigned URLs are the right answer for most cases. Here’s how the signing actually works, a complete presigned PUT upload flow, and the expiry edge cases that will bite you eventually.
What the SDK is hiding from you
When you call generate_presigned_url in boto3 or getSignedUrl in the AWS SDK, the SDK is doing three things you’re not seeing:
- Building a canonical request — a sorted, normalized string of the HTTP method, URL path, query parameters, and selected headers
- Signing it with HMAC-SHA256 — using a derived key based on your AWS secret access key, the date, region, and service (SigV4’s key derivation chain)
- Appending the signature to the URL as a query parameter —
X-Amz-Signature=abc123ef...
When your user hits that URL, S3 re-runs the same computation. If the signatures match AND the current time is before X-Amz-Expires, S3 serves the file. If either check fails: 403 Forbidden.
Your AWS secret key never leaves your server. The signature is unforgeable without the key, and an attacker can’t extend the expiry — changing that value changes the canonical string and breaks the signature. You can manually step through the raw HMAC-SHA256 computation with IO Tools’ HMAC generator to see exactly what the SDK is producing.
One detail worth knowing: if you’re using temporary IAM credentials (STS-issued, like from an assumed role), the security token is included in the presigned URL as X-Amz-Security-Token — and that token itself is a base64-encoded blob. If you’re dissecting a presigned URL for debugging, the Base64デコーダー lets you inspect it.
Expiry math
Presigned URL expiry is a duration in seconds from signing time, encoded in the X-Amz-Expires query parameter. A URL signed at 2025-03-10 14:00:00 UTC は迅速なパスだが、属性の扱いが不一致であり、エッジケースでデータを失う可能性がある。SOAPレスポンスのプロダクション用途では、 Expires=3600 is valid until 15:00:00 UTC. If you’re debugging a presigned URL and need to know what the expiry timestamp actually means in real time, paste the epoch value into the Unix timestamp converter.
Range: 1 second minimum, 604,800 seconds (7 days) maximum. You cannot presign longer than a week — AWS rejects it at signing time. If you need longer-lived access, CloudFront signed URLs are a separate mechanism with their own max and their own signing flow.
Clock skew will break you. AWS rejects requests where the signing time differs from its own clock by more than 15 minutes, with a RequestTimeTooSkewed error. This isn’t hypothetical — EC2 instance clocks drift after long pauses, especially on burstable instances that were credit-starved and paused. Make sure NTP is running on any machine that generates presigned URLs.
Presigned GET: serving private files
The straightforward case. Your backend generates a URL; the user’s browser fetches directly from S3. Your server handles a tiny JSON response — not the file bytes.
import boto3
from botocore.config import Config
s3 = boto3.client(
's3',
region_name='us-east-1',
config=Config(signature_version='s3v4')
)
url = s3.generate_presigned_url(
'get_object',
Params={
'Bucket': 'my-private-bucket',
'Key': 'invoices/invoice-2025-001.pdf',
'ResponseContentDisposition': 'attachment; filename="invoice.pdf"'
},
ExpiresIn=900 # 15 minutes
)
Two things worth calling out: always specify signature_version='s3v4' explicitly. SigV2 is deprecated and disabled in newer AWS regions — don’t let botocore silently pick the wrong one. And ResponseContentDisposition forces the browser to download the file rather than rendering it inline — useful when serving PDFs or CSVs you don’t want opening in a browser tab.
Presigned PUT: client uploads directly to S3
This is where presigned URLs really earn their keep. The standard upload flow without presigning has your backend as a dumb pipe: client sends 100 MB to your server, your server forwards 100 MB to S3. You’re burning memory and bandwidth on a middleman that adds nothing.
With presigned PUT, the flow is:
- Client requests an upload URL from your backend — tiny request, no file bytes involved
- Backend generates a presigned PUT URL and returns it
- Client uploads directly to S3 — your backend is completely out of the critical path
- Client notifies your backend the upload finished → you validate with a
HeadObjectcall
# Backend: generate the presigned PUT URL
upload_url = s3.generate_presigned_url(
'put_object',
Params={
'Bucket': 'my-private-bucket',
'Key': f'uploads/{user_id}/{filename}',
'ContentType': 'video/mp4', # must match what the client actually sends
},
ExpiresIn=3600 # 1 hour — generous for large files
)
// Client: upload directly to S3
const response = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'video/mp4', // must match exactly
},
body: file,
})
if (response.ok) {
// Notify your backend the upload completed
await fetch('/api/uploads/complete', {
method: 'POST',
body: JSON.stringify({ key: filename }),
})
}
の Content-Type header has to match exactly — the signature covers it. If your backend signed for video/mp4 and the client sends application/octet-stream, S3 returns 403. This trips up almost everyone on their first presigned PUT implementation.
CORS for presigned PUT
Frequently missed: browser uploads to S3 require a CORS policy on the bucket that explicitly allows PUT. Without it, the browser’s preflight OPTIONS request fails and the upload never starts — often with a cryptic CORS error that doesn’t mention S3 at all.
[
{
"AllowedHeaders": ["Content-Type"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["https://yourapp.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
Expiry gotchas for uploads
A 15-minute expiry is fine for presigned GET. For PUT, you have to think harder:
- Large files: A 5 GB video over a 10 Mbps connection takes about 70 minutes. A 1-hour expiry will kill it mid-upload. Either use S3 multipart uploads (each part gets its own presigned URL with its own expiry window) or give generous expiry for single-part uploads.
- Mobile connections: Mobile networks drop and reconnect. If the user loses connection and retries after the expiry window, the upload fails — often with no useful error message on their end.
- Expiry gates the first byte, not the last. Once S3 starts receiving bytes from an in-progress upload, it won’t cut it off even if the URL’s expiry passes during the upload. The check happens at request initiation, not completion.
GET vs PUT: what actually differs
| Presigned GET | Presigned PUT | |
|---|---|---|
| Typical expiry | 5–60 minutes | 15 minutes – 7 days |
| Bytes through your server | なし | なし |
| CORS required | Only if cross-origin | Yes (bucket CORS policy) |
| Content-Type enforced | いいえ | Yes (signature covers it) |
| Who initiates | User (browser, mobile app) | Your client code |
When to use presigned URLs vs the alternatives
Use a CDN (CloudFront) instead when you’re serving the same files to many different users. CDN caching collapses thousands of S3 GetObject calls into one. If you’re building a media platform where the same video is watched by 10,000 people, presigned S3 URLs generate 10,000 API calls per view. CloudFront generates one. CloudFront signed URLs also support longer expiry windows and per-IP restrictions.
Just make the bucket public when the content genuinely is public — marketing images, documentation assets, public software downloads. Engineering time spent protecting things that don’t need protecting is waste.
Use presigned URLs when files belong to specific users (invoices, medical records, private uploads), you need time-limited access (a 24-hour share link), or you want to move client uploads off your server’s critical path with presigned PUT.
R2 and GCS
Cloudflare R2 supports S3-compatible presigned URLs — point the AWS SDK at your R2 bucket endpoint and the signing mechanism is identical (SigV4, same HMAC-SHA256 derivation, same 7-day max). The @aws-sdk/s3-request-presigner package works against R2 out of the box.
Google Cloud Storage uses its own signing implementation but the concept is identical: storage.Client().bucket(name).blob(key).generate_signed_url(expiration=timedelta(hours=1)). Max expiry is also 7 days. One difference: GCS signed URLs can use either HMAC-SHA256 (with HMAC keys) or RSA-SHA256 (with service account JSON keys), depending on how your application authenticates.
恵 スコアボードが到着しました!
スコアボード ゲームを追跡する楽しい方法です。すべてのデータはブラウザに保存されます。さらに多くの機能がまもなく登場します!
