The standard advice for letting users upload files is: keep the bucket private,
have your server mint a presigned URL, let the browser PUT straight to S3. Your
credentials never reach the client and the bytes never touch your server. That
part is correct and most tutorials get it right.
Here is the part they leave out. A presigned PUT URL signs the method, the key
and the expiry. It does not constrain the body. You minted that URL for a
200 KB avatar. A client can send three gigabytes to it instead, and S3 will
take them, up to the 5 GB single-PUT ceiling.
Nothing is broken. That is what a presigned PUT is. What you published is an
authenticated, unmetered write into a bucket you pay for, and an attacker does
not need to break anything: they use the URL you gave them, repeatedly, with
large bodies. Your storage bill is the attack surface.
The defence that does not work
Almost everyone's first fix is to pass the length to the signer:
// Looks right. Does nothing.
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: 'your-bucket',
Key: key,
ContentLength: 204800,
}), { expiresIn: 300 });
Check what came back. If X-Amz-SignedHeaders in that URL says host and
nothing else, the signature does not cover the length. The client declares
whatever it likes in its own Content-Length and S3 has nothing to compare it
against.
This is the whole trap: passing a value to a signer is not the same as signing
it. Most SDK presign helpers produce exactly that URL, and it looks like it
worked.
Fix one: the POST policy
A presigned POST is a different operation, not a variant of PUT. It carries a
policy document, signed, that S3 evaluates before the object exists:
// @aws-sdk/s3-presigned-post
const { url, fields } = await createPresignedPost(s3, {
Bucket: 'your-bucket',
Key: `u/${userId}/${crypto.randomUUID()}`,
Conditions: [
['content-length-range', 1, 2 * 1024 * 1024], // enforced at the door
['starts-with', '$Content-Type', 'image/'],
],
Expires: 60,
});
The browser posts multipart/form-data with those fields plus the file.
Anything outside the policy never becomes an object. If you are on AWS and can
live with a multipart form on the client, stop here, this is the answer.
Two reasons you might not be able to.
The client gets more complicated. fetch(url, { method: 'PUT', body: file })
becomes building a FormData with fields in the right order, which is most of
why tutorials teach PUT in the first place.
And Cloudflare R2 does not implement presigned POST at all. It is absent
from R2's S3 compatibility tables and attempts come back InvalidArgument. If
your bucket is R2, the code above is not available to you.
Fix two: sign the length into the PUT
This is the one I could not find written down anywhere, and we needed it
because our default store is R2.
SigV4 covers every header named in X-Amz-SignedHeaders. So put
content-length in there. The store recomputes the signature from the headers
it actually received: a client issued a URL for 204,800 bytes that sends three
gigabytes produces a different canonical request, a different signature, and a
403 with nothing stored.
Here is a complete presign, no SDK and no dependencies, about sixty lines:
import { createHmac, createHash } from 'node:crypto';
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) =>
`%${c.charCodeAt(0).toString(16).toUpperCase()}`);
const canonicalQuery = (q) => Object.keys(q).sort()
.map((k) => `${enc(k)}=${enc(q[k])}`).join('&');
const sha256 = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
export function presignPut({
accessKeyId, secretAccessKey, sessionToken,
endpoint, bucket, region = 'auto',
key, contentLength, contentType, expiresInSeconds = 300, now = new Date(),
}) {
const amzDate = now.toISOString().replace(/[-:]|\.\d{3}/g, '');
const dateStamp = amzDate.slice(0, 8);
const scope = `${dateStamp}/${region}/s3/aws4_request`;
const base = new URL(endpoint);
const canonicalUri = `/${bucket}/${key.split('/').map(enc).join('/')}`;
// THE WHOLE FEATURE IS THIS LINE.
const signedHeaders = 'content-length;content-type;host';
const query = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${accessKeyId}/${scope}`,
'X-Amz-Date': amzDate,
'X-Amz-Expires': String(expiresInSeconds),
'X-Amz-SignedHeaders': signedHeaders,
};
if (sessionToken) query['X-Amz-Security-Token'] = sessionToken;
const canonicalRequest = [
'PUT',
canonicalUri,
canonicalQuery(query),
// Sorted by lowercase header name. content-length < content-type < host,
// which happens to be the order you want anyway.
`content-length:${contentLength}\ncontent-type:${contentType}\nhost:${base.host}\n`,
signedHeaders,
// Correct, and not a weakness: the body does not exist at signing time, so
// its hash cannot be signed. Its LENGTH still is.
'UNSIGNED-PAYLOAD',
].join('\n');
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n');
let k = Buffer.from(`AWS4${secretAccessKey}`, 'utf8');
for (const part of [dateStamp, region, 's3', 'aws4_request']) {
k = createHmac('sha256', k).update(part, 'utf8').digest();
}
query['X-Amz-Signature'] = createHmac('sha256', k).update(stringToSign, 'utf8').digest('hex');
return {
url: `${base.origin}${canonicalUri}?${canonicalQuery(query)}`,
headers: { 'content-length': String(contentLength), 'content-type': contentType },
};
}
The client must then send both headers verbatim:
await fetch(url, { method: 'PUT', headers, body: file });
That is the cost, and it is smaller than switching to multipart/form-data.
Proof that the length is actually covered
Sign the same request four times, changing only what the client sends:
issued for 204800 bytes : 78a3d4053da47b2d0174
client sends 3 GB : be25cac496620f737f36 different -> 403
client sends 204799 : ff0fdc084cc238c93752 different -> 403
client claims text/html : 4fda2306c8f590620bb4 different -> 403
One byte off and the signature changes. That is what "signed" means, and it is
what passing ContentLength to an SDK helper does not give you.
It is stricter than the POST policy, and that is a trade
content-length-range is a range. A signed content-length is an exact value.
You lose the ability to say "anything between 1 byte and 2 MiB".
In a browser that costs you nothing, because file.size is known before you
ask your server for the URL. Ask for a URL for exactly that many bytes. It
matters if you genuinely do not know the size up front, and then POST is your
answer.
What the signed PUT does not give you either way: starts-with conditions,
a policy expiry independent of the URL, and any constraint on a header you did
not sign.
Three things the size limit does not fix
The declared content type is attacker controlled. Signing content-type
pins it to what your server chose, which is good, but your server chose it from
something the client told it. Decide the real type from the first bytes after
the upload lands: 89 50 4E 47 0D 0A 1A 0A is PNG, FF D8 FF is JPEG,
25 50 44 46 is %PDF. Serve user content as an attachment with
X-Content-Type-Options: nosniff regardless, because that is the defence that
holds when the sniffing is wrong.
The object key is not access control. A long random key is not a secret.
Keys end up in logs, in Referer headers, in backups, in anything that has
listed the bucket. Either every read is authorized by your server, or the file
is public and you have not noticed.
The upload happens outside your transaction. The browser uploads, your
database knows nothing. Write a pending row before you issue the URL, flip it
to ready on a callback or an S3 event, and run a job that deletes pending
rows older than some window along with whatever they point at. If the browser
calls that callback, verify with a HEAD against the store rather than
believing the size the client reports.
Disclosure
I work on an open source library that does uploads this way, which is why I
went looking for this and ended up writing the signing code by hand. The
sixty-line presign above is not a sketch: it produces byte-identical URLs to
the implementation we run, which is tested against live S3 and live R2 on every
commit, including a test that the store itself refuses a body that is not the
signed length.
Top comments (0)