Store and Serve User-Uploaded Images in Node.js
Accept, validate, store, serve, replace, and back up public images without exposing private documents or trusting upload metadata.
On this page
An image upload crosses several trust boundaries: the browser chooses the filename and declared MIME type, the server parses a multipart body, storage persists bytes, and a public URL may expose those bytes to anyone. Treat the upload as untrusted until its size, decoded format, storage name, and access policy have been verified.
Public profile photos and product images can have stable public URLs. Passports, citizenship documents, licences, medical records, and similar identity files must use private storage and an authenticated download path. Do not place sensitive documents under a public static directory.
Multipart, Express, and Multer have different jobs
HTML file forms send multipart/form-data, which divides text fields and binary files into parts. Express routes and authorizes the request; Multer parses the multipart stream and exposes accepted file metadata on req.file or req.files. Multer does not prove that an uploaded file is a safe image.
The official Multer documentation recommends attaching upload middleware only to routes that handle files, not globally. Configure limits because Multer otherwise permits an unlimited fileSize, file count, and part count by default.
import crypto from "node:crypto";
import path from "node:path";
import multer from "multer";
const uploadDir = path.resolve("var/uploads/staging");
const allowedMime = new Set(["image/jpeg", "image/png", "image/webp"]);
const storage = multer.diskStorage({
destination: uploadDir,
filename: (_request, _file, callback) => {
callback(null, `${crypto.randomUUID()}.upload`);
},
});
export const uploadImage = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024,
files: 1,
fields: 5,
parts: 6,
},
fileFilter: (_request, file, callback) => {
callback(null, allowedMime.has(file.mimetype));
},
}).single("image");The generated UUID avoids collisions and ignores the user-controlled filename. The .upload suffix prevents a staging file from being treated as a published image before content validation. The 5 MB limit is an example; set limits from the actual product requirements, image processing cost, storage budget, and reverse-proxy limits.
file.mimetype comes from the multipart request and is only an early allowlist. OWASP explicitly warns that Content-Type is spoofable and recommends combining extension, MIME, file-signature, generated-name, size, authorization, and storage controls in depth in its File Upload Cheat Sheet.
Decode the image before publishing it
Use a maintained image library to decode the bytes, enforce maximum pixel dimensions, discard unsafe metadata where required, and write a normalized output format. Successful decoding is stronger evidence than trusting .jpg or image/jpeg, although no single check removes every parser risk.
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
const publicImageDir = path.resolve("var/uploads/public-images");
async function normalizeImage(stagingPath: string) {
const image = sharp(stagingPath, {
limitInputPixels: 25_000_000,
failOn: "warning",
});
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Image dimensions are missing");
}
const storedName = `${path.basename(stagingPath, ".upload")}.webp`;
const finalPath = path.join(publicImageDir, storedName);
await image.rotate().webp({ quality: 82 }).toFile(finalPath);
const stat = await fs.stat(finalPath);
return {
storedName,
finalPath,
mimeType: "image/webp",
sizeBytes: stat.size,
width: metadata.width,
height: metadata.height,
};
}path.basename strips directory components, and the final extension comes from the format the server generated—not from originalname. Never concatenate a submitted filename into a filesystem path. Keep the storage directory fixed by server configuration and run the application account without execute permission on uploaded files.
SVG, HTML, JavaScript, shell scripts, and server-side executable extensions should not enter a raster-image upload flow. SVG is active XML-based content with a different threat model; support it only through a separate, carefully sanitized policy.
Keep metadata in the database, bytes in storage
A useful metadata record might contain:
CREATE TABLE media_object (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
storage_key text NOT NULL UNIQUE,
original_name text,
mime_type text NOT NULL,
size_bytes bigint NOT NULL,
width integer NOT NULL,
height integer NOT NULL,
visibility text NOT NULL CHECK (visibility IN ('public', 'private')),
checksum_sha256 text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Store a server-generated storage key, detected output type, size, dimensions, checksum, ownership, and visibility. Keep the original filename only as untrusted display metadata, with a length limit and output escaping. Do not use it as the disk name or authorization decision.
For a public image, derive a URL from a configured media origin and encoded storage key. Store the key rather than permanently baking one CDN hostname into every relational row when environments can differ.
Serve public images with Express
Express provides express.static for public assets and recommends an absolute filesystem path when process working directories may vary:
import express from "express";
import path from "node:path";
const app = express();
const publicImageDir = path.resolve("var/uploads/public-images");
app.use(
"/media",
express.static(publicImageDir, {
index: false,
dotfiles: "deny",
fallthrough: false,
immutable: true,
maxAge: "1y",
}),
);Immutable caching is safe only when every replacement gets a new storage key. If a URL can change in place, use revalidation or a shorter cache lifetime. Configure Nginx, a CDN, or another reverse proxy to preserve the correct content type, cap request bodies, rate-limit abusive uploads/downloads, and serve the same media directory or proxy route intentionally. See Deploy Node.js with PM2 and Nginx for the proxy layer.
When a Next.js frontend uses these URLs, allow only the actual media host and path in images.remotePatterns. The Next.js Image 400 guide explains why a host, path, port, or query mismatch is rejected.
Private documents need an authorized download path
Keep private objects outside the webroot or in a private object-storage bucket. On every request, authenticate the user, check ownership or role, look up the server-controlled storage key, and either stream the file with safe response headers or issue a short-lived signed URL. Do not expose bucket keys that grant broader access.
Apply stricter retention, audit, encryption, malware-scanning, and logging policies to identity documents. Logs should record a safe media ID and outcome, not document bytes, extracted contents, signed URLs, or personal filenames.
Local disk versus object storage
Local filesystem storage can suit one persistent server when the upload directory is mounted on durable storage, included in backups, and shared appropriately with every process that must serve it. It becomes difficult across multiple instances because each instance has a different disk.
Serverless and many container platforms provide ephemeral writable filesystems. A successful write may disappear on restart, replacement, or a request running on another instance. Use object storage or an explicitly persistent mounted volume for permanent uploads. Object storage also supports direct-to-storage uploads, lifecycle policies, replication, and CDN delivery, but requires careful bucket policy, signed upload constraints, CORS, and cleanup.
Database backups do not automatically include filesystem or object-storage bytes. Back up the media store and metadata consistently enough to reconstruct their relationship, and run restore drills plus missing/orphan object checks.
Handle failure across storage and database writes
Filesystem/object storage and PostgreSQL do not share one atomic transaction. Use a compensating workflow:
- Upload into a staging key.
- Validate and normalize the image.
- Create the final object using a new generated key.
- Insert its metadata and attach it to the owner in a database transaction.
- If the database transaction fails, delete or enqueue cleanup for the new object.
- Remove the staging object in
finally.
For replacement, publish and attach the new image first. After the database commits, enqueue deletion of the old key. This ordering avoids deleting the only working image before the replacement is durable. Make cleanup idempotent and run a scheduled reconciliation for abandoned staging and unreferenced objects.
router.post("/profile/image", requireUser, uploadImage, async (req, res, next) => {
const stagingPath = req.file?.path;
if (!stagingPath) return res.status(400).json({ error: "Image required" });
let normalized: Awaited<ReturnType<typeof normalizeImage>> | undefined;
try {
normalized = await normalizeImage(stagingPath);
const media = await saveAndAttachMedia(req.user.id, normalized);
res.status(201).json({ id: media.id, url: media.publicUrl });
} catch (error) {
if (normalized) await fs.rm(normalized.finalPath, { force: true }).catch(() => undefined);
next(error);
} finally {
await fs.rm(stagingPath, { force: true }).catch(() => undefined);
}
});Do not hide the original upload/database error if cleanup also fails. Report cleanup separately to monitoring and allow reconciliation to retry it.
Verification checklist
- The route requires the intended authentication and CSRF protection.
- Multipart file, field, part, byte, and pixel limits are enforced.
- The server decodes and normalizes allowed images instead of trusting MIME or extension alone.
- Generated storage keys cannot contain user-controlled paths or executable extensions.
- Public and private objects use separate access policies and storage locations.
- Reverse proxy, CDN, Express, and frontend image-host settings agree.
- Replacement and failure cleanup are idempotent and monitored.
- Media bytes and metadata are both backed up and restore-tested.
References
Documentation checked on 2026-08-12:
Related writing
- Deploy a Node.js Application on DirectAdmin or cPanel Shared HostingEvaluate shared-hosting Node.js support and deploy through cPanel Passenger or DirectAdmin Nginx Unit with realistic platform limits.
- Deploy a Node.js Application with PM2 and NginxBuild and run a Node.js application with PM2, proxy it through Nginx, preserve client headers, and verify the deployment safely.
- Deploy a FastAPI OCR Service with Docker and TesseractPackage a FastAPI OCR endpoint with Tesseract, safe upload handling, health checks, limits, and production verification.