A presigned URL is a bearer token. Anyone who gets the string can use it until it expires — no login, no session check, no second factor. That one fact decides every design choice below, so lead with it: generate the URL server-side with a least-privilege role, keep the expiry short, choose the object key yourself, and make the client send exactly the method and headers you signed.
The problem it solves is proxying. If your API receives a 600 MB video only to forward the same 600 MB to S3, it's burning bandwidth and a request slot on work it doesn't need to touch. A presigned URL cuts the middle out: your server authorizes one specific S3 operation, then the browser uploads directly to S3 and your AWS credentials never leave the server.
The request you are actually authorizing
A presigned URL is not a temporary bucket policy and it is not a login session for the client. It carries a signature made with the permissions of the IAM principal that generated it. S3 checks that signature when the request arrives. AWS documents the inputs plainly: bucket, object key, HTTP method, and expiry. The recipient can use the URL repeatedly until it expires, so it should be handled like a secret, not pasted into an issue or analytics event. Amazon S3's presigned URL guide is clear on both points.
| You need | Presign | What the client gets |
|---|---|---|
| Let a user download one private object | GetObject | A GET URL |
| Let a user upload to one chosen key | PutObject | A PUT URL |
| Confirm an object exists or read metadata | HeadObject | A HEAD URL |
| Enforce form-style upload conditions | Presigned POST | A URL plus fields and policy |
For a normal application upload, your API should authenticate the user first, create a non-guessable key such as uploads/<account-id>/<uuid>.png, and then return only that key and a short-lived PUT URL. Do not let the client ask to sign arbitrary bucket keys unless overwriting other users' files is a product feature.
A small, correct TypeScript upload flow
This server-side route creates a five-minute URL. ContentType is deliberately part of the command: if you sign it, the uploader must send the same value.
import { randomUUID } from "node:crypto";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function createUploadUrl(userId: string) {
const key = `uploads/${userId}/${randomUUID()}.png`;
const contentType = "image/png";
const command = new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: key,
ContentType: contentType,
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
return { key, contentType, uploadUrl };
}
The browser then uploads the bytes directly:
const response = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": contentType },
body: file,
});
if (!response.ok) {
throw new Error(`S3 upload failed: ${response.status}`);
}
This split is the whole point: the application server decides who, where, what type, and how long; S3 handles the transfer. For browsers on another origin, add a bucket CORS rule that permits your application origin, PUT, and the headers you actually send. “Allow everything” is tempting during the first CORS error and tends to survive far longer than it should.
A presigned URL has no idea whether the person holding it is still logged in to your app. Revoking the session does not un-send a URL already issued. Keep expiry short and issue a fresh URL rather than trying to make a long-lived one safe.
The expiry you asked for is not always the expiry you get
expiresIn: 604800 (seven days) looks like it grants a week. It doesn't, if you're running on an IAM role — and in production you almost always are. A presigned URL can never outlive the credentials that signed it, and a role session is short:
| Signing credential | Your expiresIn | Actual working lifetime |
|---|---|---|
| IAM role on Lambda / ECS | 604800 (7 days) | ~15 min — dies with the session, then ExpiredToken |
Assumed role, DurationSeconds: 3600 | 604800 (7 days) | 1 hour |
| Long-lived IAM user key | 604800 (7 days) | 7 days (the SigV4 max) |
The takeaway: if a URL you signed for hours starts throwing ExpiredToken after fifteen minutes, you're not looking at a bug — you're looking at the role's session length capping your request. The fix is not a longer expiresIn; it's issuing URLs on demand from a fresh session, which is what you wanted for security anyway.
Why SignatureDoesNotMatch keeps happening
The signature covers the shape of the request. Change part of that shape in transit and S3 is right to reject it. The quickest triage is to compare the signing inputs with the actual request.
| Symptom | Usually means | Check first |
|---|---|---|
SignatureDoesNotMatch | Request differs from the signed request | HTTP method, region, query string, and signed headers |
403 AccessDenied | Signing role or bucket policy denies the action | s3:PutObject/s3:GetObject on that exact prefix, plus explicit denies |
ExpiredToken or an unexpectedly early expiry | Temporary credentials expired before the URL's requested lifetime | Role-session duration and credential rotation |
| Upload succeeds but has the wrong MIME type | Client omitted or changed Content-Type | Return the expected type with the URL and send it verbatim |
For a presigned PUT, AWS specifically calls out matching the upload Content-Type to the value used at signing time. Its upload guide also confirms the less obvious behaviour: an upload to an existing signed key replaces that object. That is why generated keys matter.
Make the URL a narrow capability
Use these defaults unless you can explain why you need broader access:
- Generate URLs with a dedicated IAM role. Give it access only to the upload prefix, not the whole bucket.
- Set a short expiry. Five minutes is a sensible upload starting point; seven days may be allowed by SigV4 with IAM user credentials, but it is usually a bad product default. Temporary credentials can shorten the effective lifetime further.
- Choose keys on the server. A UUID avoids collisions; the server-side prefix establishes tenancy. Never trust a supplied filename as the S3 key.
- Validate after upload.
Content-Typeis metadata supplied by the client. If you serve user files, inspect or transform them before publishing them from a trusted domain. - Use checksums when integrity matters. SigV4 presigned uploads support checksum headers; make the checksum part of the intended request rather than trusting the network transfer blindly.
If you need to enforce a maximum file size or tightly constrain form fields before the object lands, use a presigned POST policy instead of pretending a PUT URL is a complete upload-validation layer. Either way, the application still owns authorization and post-upload validation.
FAQ
Can a presigned URL be revoked before it expires?
Not individually. There's no "cancel this URL" API. Your options are blunt: rotate or delete the signing credentials (which kills every URL they signed), or attach a bucket policy that denies the action. In practice you don't revoke — you keep the expiry short enough that revocation isn't the tool you reach for.
Is the URL safe to log or put in analytics?
No. The signature is in the query string, so the full URL is a working credential for whoever reads your logs. Log the object key, not the signed URL, and keep it out of Referer headers, error trackers, and client-side analytics events.
Why does my upload succeed but the file is unreadable / wrong type?
You signed a Content-Type the client didn't send, or the reverse. If ContentType is part of the PutObjectCommand, the browser's PUT must send the identical header or you get SignatureDoesNotMatch; if it's absent from both, S3 stores application/octet-stream and the browser later downloads instead of rendering it. Return the expected type alongside the URL and send it verbatim.
One presigned PUT or multipart for large files?
A single presigned PUT is fine up to 5 GB. Past that — or when you want resumable uploads over flaky connections — presign each part of a multipart upload separately and have the client assemble them. A single URL for a 40 GB file is a bad time.
The useful mental model
Presigning is capability delegation, not authentication. You mint a small, expiring ability to perform one storage action; whoever has that capability can exercise it until it expires. Design the URL as if it will be copied, logged, and discovered five minutes later—because eventually one will be.
That posture makes direct S3 uploads both faster and less scary: your API stops proxying bytes, and your AWS credentials remain exactly where they belong.
Cover photo by panumas nikhomkhai on Pexels.
