October 23, 2026
Stop Uploading Files Through Your Node.js Server. Use Presigned URLs
Sign a short-lived POST policy, let the browser send the file straight to storage, then check what actually landed

By Martin
7 min read
Ten users upload a 50 MB video at the same time, and the API's memory goes from 36 MB to 572 MB. That's a plain Node.js handler that reads the body into a Buffer before passing it on to storage.
On a container with a 512 MB limit, that's an out-of-memory kill. Even with streaming, every upload still holds a connection and a slice of bandwidth on a server that should be answering API calls.
I will be sharing the approach I've been using in multiple projects: the API signs a short-lived upload policy, the browser sends the file directly to S3-compatible storage, and the API confirms it at the end. I ran everything against a local MinIO, with no AWS SDK, and the output is at the end.
This article expects you to have minimum knowledge of Node.js and S3, but nothing is really complicated here.
Through the server vs straight to storage
Think of a parcel locker. The shop doesn't carry your parcel through its office, it gives you a one-time code for one locker, and the locker checks the code.
A presigned upload is that code. The API signs a request with its storage secret, the browser gets the signed request but never the secret, and storage checks the signature itself.
Folder structure
Step 1: SigV4 without an SDK
S3, MinIO and most other S3-compatible stores use Signature Version 4. The key part is a signing key derived from your secret, the date, the region and the service:
// src/sigv4.js (SigV4 for S3, no SDK)
import crypto from 'node:crypto';
const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest();
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
const enc = (s) => encodeURIComponent(s)
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
export function signingScope({ accessKeyId, secretAccessKey, region }, now = new Date()) {
const amzDate = now.toISOString().replace(/[-:]|\.\d{3}/g, ''); // 20261013T120000Z
const day = amzDate.slice(0, 8);
const key = [day, region, 's3', 'aws4_request'].reduce(hmac, `AWS4${secretAccessKey}`);
return { amzDate, key, scope: `${day}/${region}/s3/aws4_request`,
credential: `${accessKeyId}/${day}/${region}/s3/aws4_request` };
}
export function presignUrl(cfg, method, objectKey, expiresSec = 300) {
const s = signingScope(cfg);
const path = `/${cfg.bucket}/${objectKey.split('/').map(enc).join('/')}`;
const params = { 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', 'X-Amz-Credential': s.credential,
'X-Amz-Date': s.amzDate, 'X-Amz-Expires': String(expiresSec), 'X-Amz-SignedHeaders': 'host' };
const query = Object.keys(params).sort().map((k) => `${enc(k)}=${enc(params[k])}`).join('&');
const canonical = [method, path, query, `host:${new URL(cfg.endpoint).host}\n`, 'host',
'UNSIGNED-PAYLOAD'].join('\n');
const toSign = ['AWS4-HMAC-SHA256', s.amzDate, s.scope, sha256(canonical)].join('\n');
return `${cfg.endpoint}${path}?${query}&X-Amz-Signature=${hmac(s.key, toSign).toString('hex')}`;
}// src/sigv4.js (SigV4 for S3, no SDK)
import crypto from 'node:crypto';
const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest();
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
const enc = (s) => encodeURIComponent(s)
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
export function signingScope({ accessKeyId, secretAccessKey, region }, now = new Date()) {
const amzDate = now.toISOString().replace(/[-:]|\.\d{3}/g, ''); // 20261013T120000Z
const day = amzDate.slice(0, 8);
const key = [day, region, 's3', 'aws4_request'].reduce(hmac, `AWS4${secretAccessKey}`);
return { amzDate, key, scope: `${day}/${region}/s3/aws4_request`,
credential: `${accessKeyId}/${day}/${region}/s3/aws4_request` };
}
export function presignUrl(cfg, method, objectKey, expiresSec = 300) {
const s = signingScope(cfg);
const path = `/${cfg.bucket}/${objectKey.split('/').map(enc).join('/')}`;
const params = { 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', 'X-Amz-Credential': s.credential,
'X-Amz-Date': s.amzDate, 'X-Amz-Expires': String(expiresSec), 'X-Amz-SignedHeaders': 'host' };
const query = Object.keys(params).sort().map((k) => `${enc(k)}=${enc(params[k])}`).join('&');
const canonical = [method, path, query, `host:${new URL(cfg.endpoint).host}\n`, 'host',
'UNSIGNED-PAYLOAD'].join('\n');
const toSign = ['AWS4-HMAC-SHA256', s.amzDate, s.scope, sha256(canonical)].join('\n');
return `${cfg.endpoint}${path}?${query}&X-Amz-Signature=${hmac(s.key, toSign).toString('hex')}`;
}presignUrl makes a query-signed URL for GET, HEAD or PUT. That reduce is the whole key derivation: four HMACs in a row, starting from AWS4 plus the secret.
This uses path-style URLs (endpoint/bucket/key), which MinIO expects by default. On AWS, check whether your bucket needs the virtual-hosted style.
Step 2: POST policy, not a PUT URL
The common tutorial answer is a presigned PUT URL. The catch is that a plain PUT URL says nothing about size, and in my test it happily took a 50 MB body.
A POST policy is a signed JSON document with conditions. content-length-range sets the size limit, and an exact match on key and Content-Type stops the client from changing them.
// src/presign-post.js
import { createHmac } from 'node:crypto';
import { signingScope } from './sigv4.js';
export function presignPost(cfg, objectKey, contentType, maxBytes, expiresSec = 300) {
const s = signingScope(cfg);
const fields = {
key: objectKey,
'Content-Type': contentType,
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
'x-amz-credential': s.credential,
'x-amz-date': s.amzDate,
};
const policy = Buffer.from(JSON.stringify({
expiration: new Date(Date.now() + expiresSec * 1000).toISOString(),
conditions: [
{ bucket: cfg.bucket },
...Object.entries(fields).map(([name, value]) => ({ [name]: value })), // exact matches
['content-length-range', 1, maxBytes], // S3 rejects anything outside this
],
})).toString('base64');
const signature = createHmac('sha256', s.key).update(policy).digest('hex');
return { url: `${cfg.endpoint}/${cfg.bucket}`, fields: { ...fields, policy, 'x-amz-signature': signature } };
}// src/presign-post.js
import { createHmac } from 'node:crypto';
import { signingScope } from './sigv4.js';
export function presignPost(cfg, objectKey, contentType, maxBytes, expiresSec = 300) {
const s = signingScope(cfg);
const fields = {
key: objectKey,
'Content-Type': contentType,
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
'x-amz-credential': s.credential,
'x-amz-date': s.amzDate,
};
const policy = Buffer.from(JSON.stringify({
expiration: new Date(Date.now() + expiresSec * 1000).toISOString(),
conditions: [
{ bucket: cfg.bucket },
...Object.entries(fields).map(([name, value]) => ({ [name]: value })), // exact matches
['content-length-range', 1, maxBytes], // S3 rejects anything outside this
],
})).toString('base64');
const signature = createHmac('sha256', s.key).update(policy).digest('hex');
return { url: `${cfg.endpoint}/${cfg.bucket}`, fields: { ...fields, policy, 'x-amz-signature': signature } };
}The signature is just an HMAC of the base64 policy with the signing key. Every form field needs a matching condition, and the file has to be the last field in the form.
Step 3: never trust the filename
// src/uploads.js
import crypto from 'node:crypto';
import { presignPost } from './presign-post.js';
import { presignUrl } from './sigv4.js';
const ALLOWED = new Map([['image/png', '.png'], ['image/jpeg', '.jpg'], ['application/pdf', '.pdf']]);
const MAX_BYTES = 5 * 1024 * 1024;
export async function createUpload(db, s3, userId, { filename, contentType, size }) {
const ext = ALLOWED.get(contentType); // a Map, so 'constructor' isn't a type
if (!ext) return { status: 415, error: 'file type not allowed' };
if (!Number.isInteger(size) || size < 1 || size > MAX_BYTES) return { status: 413, error: 'too large' };
const id = crypto.randomUUID();
const key = `quarantine/${userId}/${id}${ext}`; // never the user's filename
await db.insert({ id, userId, key, contentType, filename, status: 'pending' });
return { status: 201, id, ...presignPost(s3, key, contentType, MAX_BYTES, 300) };
}// src/uploads.js
import crypto from 'node:crypto';
import { presignPost } from './presign-post.js';
import { presignUrl } from './sigv4.js';
const ALLOWED = new Map([['image/png', '.png'], ['image/jpeg', '.jpg'], ['application/pdf', '.pdf']]);
const MAX_BYTES = 5 * 1024 * 1024;
export async function createUpload(db, s3, userId, { filename, contentType, size }) {
const ext = ALLOWED.get(contentType); // a Map, so 'constructor' isn't a type
if (!ext) return { status: 415, error: 'file type not allowed' };
if (!Number.isInteger(size) || size < 1 || size > MAX_BYTES) return { status: 413, error: 'too large' };
const id = crypto.randomUUID();
const key = `quarantine/${userId}/${id}${ext}`; // never the user's filename
await db.insert({ id, userId, key, contentType, filename, status: 'pending' });
return { status: 201, id, ...presignPost(s3, key, contentType, MAX_BYTES, 300) };
}The key naming rule is simple: the server builds the key from ids it owns. A filename like ../../avatar.png or index.html never reaches storage, and two users with photo.jpg can't overwrite each other.
The extension comes from the allowlist, not the filename. The original name is kept in the database, for a Content-Disposition header when the file is downloaded.
Note: I first wrote the allowlist as a plain object.
ALLOWED['constructor']is then a function, which is truthy, so a crafted content type got through. AMaphas no inherited keys.
Step 4: the browser sends the file
// web/upload.js (browser)
export async function uploadFile(file) {
const res = await fetch('/api/uploads', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size }),
});
if (!res.ok) throw new Error((await res.json()).error);
const { id, url, fields } = await res.json();
const form = new FormData();
for (const [name, value] of Object.entries(fields)) form.append(name, value);
form.append('file', file); // must be the last field
const sent = await fetch(url, { method: 'POST', body: form });
if (!sent.ok) throw new Error(`storage refused the upload: ${sent.status}`);
const done = await fetch(`/api/uploads/${id}/complete`, { method: 'POST' });
if (!done.ok) throw new Error('upload could not be confirmed');
return id;
}// web/upload.js (browser)
export async function uploadFile(file) {
const res = await fetch('/api/uploads', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size }),
});
if (!res.ok) throw new Error((await res.json()).error);
const { id, url, fields } = await res.json();
const form = new FormData();
for (const [name, value] of Object.entries(fields)) form.append(name, value);
form.append('file', file); // must be the last field
const sent = await fetch(url, { method: 'POST', body: form });
if (!sent.ok) throw new Error(`storage refused the upload: ${sent.status}`);
const done = await fetch(`/api/uploads/${id}/complete`, { method: 'POST' });
if (!done.ok) throw new Error('upload could not be confirmed');
return id;
}The size check in createUpload is only for a friendly early error. The real limit is the one inside the signed policy, because the browser can lie about file.size.
Tip: the bucket needs a CORS rule that allows
POSTfrom your site's origin, or the browser blocks the request before it starts.
Step 5: confirm what landed
// src/uploads.js (continued)
export async function completeUpload(db, s3, userId, id) {
const row = await db.get(id);
if (!row || row.userId !== userId || row.status !== 'pending') return { status: 404 };
const head = await fetch(presignUrl(s3, 'HEAD', row.key, 60), { method: 'HEAD' });
if (head.status === 404) return { status: 409, error: 'nothing uploaded yet' };
if (!head.ok) throw new Error(`storage HEAD failed: ${head.status}`);
const size = Number(head.headers.get('content-length'));
if (head.headers.get('content-type') !== row.contentType || size > MAX_BYTES) {
return { status: 422, error: 'stored object does not match' };
}
await db.update(id, { status: 'uploaded', size, etag: head.headers.get('etag') }); // scanner picks it up
return { status: 200, id, size };
}// src/uploads.js (continued)
export async function completeUpload(db, s3, userId, id) {
const row = await db.get(id);
if (!row || row.userId !== userId || row.status !== 'pending') return { status: 404 };
const head = await fetch(presignUrl(s3, 'HEAD', row.key, 60), { method: 'HEAD' });
if (head.status === 404) return { status: 409, error: 'nothing uploaded yet' };
if (!head.ok) throw new Error(`storage HEAD failed: ${head.status}`);
const size = Number(head.headers.get('content-length'));
if (head.headers.get('content-type') !== row.contentType || size > MAX_BYTES) {
return { status: 422, error: 'stored object does not match' };
}
await db.update(id, { status: 'uploaded', size, etag: head.headers.get('etag') }); // scanner picks it up
return { status: 200, id, size };
}The browser saying "done" is a claim, not proof. The API signs its own HEAD request and reads the real size and type from storage.
Quarantine first, then promote
Every upload lands under quarantine/, and nothing serves files from there. A worker scans the file, the approach from my ClamAV article, and copies clean files to a public prefix.
AWS's docs say a presigned URL can be used many times until it expires, and a POST policy has no use-once flag either. So a client could post a different file to the same key after complete.
That's why the ETag is saved. The scanner checks it still matches before promoting, and the 5 minute expiry keeps the window short.
Proving it works
This is the real run against MinIO in Docker, with my local test credentials:
ask for .exe {"status":415,"error":"file type not allowed"}
ask for 20 MB {"status":413,"error":"too large"}
ask for 1 MB png, got key quarantine/user_42/7c301377-7e82-4b13-9723-d02c96f2b387.png
complete before upload {"status":409,"error":"nothing uploaded yet"}
upload 6 MB with the 5 MB policy 400 EntityTooLarge
upload as text/html 403 AccessDenied (Invalid according to Policy: Policy Condition failed)
upload to another key 403 AccessDenied (Invalid according to Policy: Policy Condition failed)
upload 1 MB png 204
complete as another user {"status":404}
complete {"status":200,"id":"7c301377-...","size":1048576}
complete again {"status":404}
upload after policy expired 403 AccessDenied (Invalid according to Policy: Policy expired)
presigned PUT, 50 MB body 200 (no size limit in a plain PUT URL)ask for .exe {"status":415,"error":"file type not allowed"}
ask for 20 MB {"status":413,"error":"too large"}
ask for 1 MB png, got key quarantine/user_42/7c301377-7e82-4b13-9723-d02c96f2b387.png
complete before upload {"status":409,"error":"nothing uploaded yet"}
upload 6 MB with the 5 MB policy 400 EntityTooLarge
upload as text/html 403 AccessDenied (Invalid according to Policy: Policy Condition failed)
upload to another key 403 AccessDenied (Invalid according to Policy: Policy Condition failed)
upload 1 MB png 204
complete as another user {"status":404}
complete {"status":200,"id":"7c301377-...","size":1048576}
complete again {"status":404}
upload after policy expired 403 AccessDenied (Invalid according to Policy: Policy expired)
presigned PUT, 50 MB body 200 (no size limit in a plain PUT URL)The test script sends uploads the same way the browser code does, with FormData and fetch in Node 20. The 572 MB number at the top came from a separate run: 10 concurrent 50 MB uploads to a buffering handler in its own process.
Conclusion
The main takeaway is that the API should sign, not carry. Use a POST policy so storage enforces the size, type and key, build the key from your own ids, and confirm with a HEAD before you trust the upload. Next I would add multipart uploads for files over a few hundred MB, which use a different signing flow.
Hope this is helpful for everyone. Please leave a comment below for any thoughts and discussion. Happy coding ;)