Handling file uploads safely
A practical guide to handling file uploads safely in Node and Next.js: validation, presigned uploads, content-type checks, and safe storage.

File uploads are one of those features that look done after an afternoon and then quietly become the worst part of your codebase six months later. The happy path is easy. The problem is everything around it: lying clients, oversized files, malicious payloads, and the slow realization that you are now running a free file host for strangers. This is a tour of the parts that actually matter when you handle file uploads safely.
Never trust the filename or the content type
The first rule is that everything the browser sends you is a suggestion, not a fact, the same mindset you bring to any secure backend. The Content-Type header is set by the client and can say image/png for a PHP script. The filename can contain ../../etc/passwd or a Unicode trick that renders as invoice.pdf but ends in .exe. Treat all of it as hostile input.
Validate the actual bytes. For most formats you can read the first few bytes (the magic number) and confirm the file is what it claims to be. A real PNG starts with 89 50 4E 47. A JPEG starts with FF D8 FF. Libraries like file-type do this for you and are worth the dependency:
import { fileTypeFromBuffer } from "file-type";
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
export async function validateImage(buffer: Buffer) {
if (buffer.byteLength > MAX_BYTES) {
throw new Error("File too large");
}
const detected = await fileTypeFromBuffer(buffer);
if (!detected || !ALLOWED.has(detected.mime)) {
throw new Error("Unsupported file type");
}
return detected; // { ext: "png", mime: "image/png" }
}Notice that we derive the extension from the detected type, not from the name the user gave us. When you save the file, generate your own name. A UUID plus the verified extension is plenty. This kills path traversal and filename collisions in one move:
import { randomUUID } from "node:crypto";
const detected = await validateImage(buffer);
const storageKey = `uploads/${randomUUID()}.${detected.ext}`;Set limits before the bytes hit your server
Validation only helps if the request reaches your handler in one piece. Without a size cap, someone can stream a 4 GB file and exhaust your memory or disk while you wait. Set the limit at every layer that has one.
In Next.js route handlers you should check Content-Length early and stop reading if the stream runs long. If you use a parser like busboy or multer, configure its limits explicitly rather than trusting defaults:
import multer from "multer";
export const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024,
files: 1,
fields: 10,
},
});The Content-Length header can also lie, so the parser limit is the one that actually protects you: it counts bytes as they arrive and aborts mid-stream. The header check is just a cheap early reject for honest clients.
If you are on a serverless platform, remember that request body size is usually capped by the platform anyway (often around 4.5 MB on Vercel functions). That cap is a good reason to push large uploads off your server entirely, which brings us to the better pattern.
Upload straight to storage with presigned URLs
For anything beyond small images, do not route file bytes through your application server at all. Have the browser upload directly to object storage (S3, R2, GCS) using a presigned URL that your backend generates. Your server stays in the control path (it decides who can upload and what key they get) but never touches the payload.
The flow has two steps. First, the client asks your API for permission. Your API checks auth, validates the requested type and size, and returns a short-lived signed URL:
import { S3Client } from "@aws-sdk/client-s3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function createUploadUrl(userId: string, contentType: string) {
if (!["image/png", "image/jpeg"].includes(contentType)) {
throw new Error("Unsupported type");
}
const key = `users/${userId}/${randomUUID()}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET,
Key: key,
ContentType: contentType,
ContentLength: undefined, // see note below
}),
{ expiresIn: 60 }, // one minute is plenty
);
return { url, key };
}A signed URL with ContentType baked in forces the client to send that exact header. To enforce a maximum size you want a presigned POST policy (with content-length-range) rather than a plain PUT, since PUT signing does not bound the body. On Vercel Blob the equivalent is a client upload token with maximumSizeInBytes and an allowedContentTypes list, which gives you the same guarantees with less ceremony.
The second step is the part people forget: do not mark the upload as done just because you handed out a URL. The client could have failed, uploaded garbage, or never called the storage endpoint at all. Confirm it server side. Either have the client call back after a successful upload and then HeadObject to verify the file exists with the right size, or subscribe to bucket events so storage tells you when an object lands. Only then write the row to your database.
Store metadata, not blobs, in your database
Keep the file in object storage and keep a record in Postgres. The record is what your app queries; the bytes live somewhere cheap and scalable. A reasonable shape:
create table uploads (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id),
storage_key text not null unique,
mime_type text not null,
size_bytes bigint not null,
status text not null default 'pending', -- pending | ready | failed
created_at timestamptz not null default now()
);The status column matters. Rows start as pending when you issue the signed URL and flip to ready only after you have verified the object exists. A background job can sweep pending rows older than an hour and delete them, so abandoned uploads do not pile up. Storing the verified mime_type and size_bytes from your own check (not the client's claim) means later code can trust those values.
Serve files without becoming an open door
Once a file is stored, how you serve it back is its own small security surface. Two things to get right.
First, do not serve user uploads from the same origin that runs your application, and never let the browser sniff their type. If an attacker uploads an HTML file with a script in it and you serve it from your main domain, that script runs as your site. Serve uploads from a separate domain (or a dedicated bucket origin) and send Content-Type: application/octet-stream plus Content-Disposition: attachment for anything that is not an image you have verified. Add X-Content-Type-Options: nosniff so browsers stop guessing. Treat this as one item on a broader web application security checklist, not the whole job.
Second, for private files, generate short-lived signed download URLs the same way you did for uploads. Do not make the bucket public and rely on unguessable keys. UUIDs leak through logs, referer headers, and shared links, and "secret URL" is not access control.
The takeaway
Handling file uploads safely comes down to a short checklist: verify the bytes instead of the headers, cap the size at every layer, push large payloads directly to object storage with short-lived signed URLs, confirm the upload server side before trusting it, and serve files from an origin that cannot run code as your app. None of these steps is hard on its own. Skipping any one of them is how a tidy upload feature turns into an incident months later.
If your current upload flow trusts the client more than you would like, it is worth an hour to walk through this list before it becomes someone else's problem.
