How to Build a Website for Uploading Files and Images
Build a secure file and image upload website with browser validation, server authorization, object storage, scanning, and direct-to-storage uploads.
Use a browser file input, but make the server the security boundary. Authenticate the user, authorize the destination, validate file content and size against a narrow allowlist, generate the storage name yourself, and keep uploaded bytes outside the application web root. For larger files, let your server issue a short-lived, narrowly scoped upload permission for object storage so the browser transfers bytes directly.
This guide shows a complete small deployment, then explains how to move to Amazon S3 presigned uploads, Firebase Storage, or Cloudinary. It covers images and general files, private and public delivery, validation, abuse controls, failures, performance, and cost decisions.
1. Choose the upload architecture
| Approach | Best fit | Tradeoffs |
|---|---|---|
| Application receives the upload, then stores it | Small systems that need server-side inspection before storage | Uses backend bandwidth and requires request, memory, disk, timeout, and cleanup controls. |
| Amazon S3 with presigned URLs | Custom applications needing direct browser-to-object-storage transfer | Your application must authenticate, authorize, constrain, and expire each URL correctly. The S3 console documents a 160 GB per-file upload maximum; larger files require the CLI, SDK, or REST API. AWS documentation |
| Cloud Storage for Firebase | Applications already using Firebase Auth and its web SDK | Security rules, plan restrictions, quotas, and current limits need review. Firebase documents Spark-plan blocks for some executable extensions. Firebase upload documentation |
| Cloudinary | Image and video workflows needing an embedded uploader and media transformations | Review signing, quotas, rate limits, privacy, transformations, and current pricing. Cloudinary JavaScript SDK |
Decide these items before writing code:
- Who may upload, and which account owns each object?
- Which types are required: for example JPEG, PNG, WebP, PDF, or ZIP?
- What are the maximum file and request sizes?
- Are files private, public, or public only through a controlled URL?
- How long are files retained, and how are they deleted?
- Do images need resizing, format conversion, moderation, or malware scanning?
- What happens when an upload is abandoned halfway through?
2. Build the browser form
Browser checks improve usability but are not a security control. A user can bypass them with a custom request, so repeat every important check on the server.
<form id="upload-form" enctype="multipart/form-data">
<label for="files">Choose files</label>
<input id="files" name="files" type="file" accept="image/jpeg,image/png,image/webp,application/pdf" multiple>
<button type="submit">Upload</button>
<progress id="progress" value="0" max="100" hidden></progress>
<p id="status" role="status"></p>
</form>
<script>
const form = document.querySelector('#upload-form');
const input = document.querySelector('#files');
const progress = document.querySelector('#progress');
const status = document.querySelector('#status');
const maxBytes = 10 * 1024 * 1024;
const allowed = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);
form.addEventListener('submit', async (event) => {
event.preventDefault();
status.textContent = '';
if (!input.files.length) {
status.textContent = 'Choose at least one file.';
return;
}
for (const file of input.files) {
if (file.size > maxBytes) {
status.textContent = `${file.name} is larger than 10 MB.`;
return;
}
if (!allowed.has(file.type)) {
status.textContent = `${file.name} has an unsupported type.`;
return;
}
}
const data = new FormData();
for (const file of input.files) data.append('files', file);
const request = new XMLHttpRequest();
request.open('POST', '/upload');
request.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
progress.hidden = false;
progress.value = e.loaded / e.total * 100;
}
});
request.onload = () => {
if (request.status >= 200 && request.status < 300) {
status.textContent = 'Upload complete.';
form.reset();
} else {
status.textContent = request.responseText || 'Upload failed.';
}
};
request.onerror = () => { status.textContent = 'Network error. Try again.'; };
request.send(data);
});
</script>
3. Create a secure receiving endpoint
The following Node.js example uses Express and Multer for a small deployment. It limits the number and size of files, writes temporary files outside the public directory, checks magic bytes for supported formats, generates a random storage name, and stores metadata separately in a JSON file for demonstration. Replace the JSON metadata store with your database in production.
mkdir upload-site && cd upload-site
npm init -y
npm install express multer
mkdir -p public storage metadata
// server.js
const express = require('express');
const multer = require('multer');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');
const app = express();
const PORT = process.env.PORT || 3000;
const STORAGE = path.resolve('storage');
const METADATA = path.resolve('metadata/files.json');
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_FILES = 5;
const upload = multer({
dest: path.resolve('tmp-uploads'),
limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES, fields: 10 },
fileFilter: (_req, file, cb) => {
const allowed = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);
cb(null, allowed.has(file.mimetype));
}
});
async function detectedType(filePath) {
const bytes = await fs.readFile(filePath);
if (bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return { type: 'image/png', ext: 'png' };
if (bytes.subarray(0, 3).equals(Buffer.from([255,216,255]))) return { type: 'image/jpeg', ext: 'jpg' };
if (bytes.subarray(0, 4).equals(Buffer.from('RIFF')) && bytes.subarray(8, 12).equals(Buffer.from('WEBP'))) return { type: 'image/webp', ext: 'webp' };
if (bytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) return { type: 'application/pdf', ext: 'pdf' };
return null;
}
async function readMetadata() {
try { return JSON.parse(await fs.readFile(METADATA, 'utf8')); }
catch { return []; }
}
app.use(express.static('public'));
app.post('/upload', upload.array('files', MAX_FILES), async (req, res) => {
if (!req.user) {
for (const file of req.files || []) await fs.rm(file.path, { force: true });
return res.status(401).send('Sign in before uploading.');
}
const accepted = [];
try {
await fs.mkdir(STORAGE, { recursive: true });
await fs.mkdir(path.dirname(METADATA), { recursive: true });
for (const file of req.files || []) {
const detected = await detectedType(file.path);
if (!detected || detected.type !== file.mimetype) {
await fs.rm(file.path, { force: true });
continue;
}
const objectKey = `${crypto.randomUUID()}.${detected.ext}`;
const destination = path.join(STORAGE, objectKey);
await fs.rename(file.path, destination);
accepted.push({
ownerId: req.user.id,
objectKey,
detectedType: detected.type,
bytes: file.size,
uploadedAt: new Date().toISOString(),
state: 'accepted'
});
}
const existing = await readMetadata();
await fs.writeFile(METADATA, JSON.stringify(existing.concat(accepted), null, 2));
res.status(201).json({ files: accepted.map(({ objectKey, detectedType, bytes }) => ({ objectKey, detectedType, bytes })) });
} catch (error) {
for (const file of req.files || []) await fs.rm(file.path, { force: true });
res.status(500).send('The upload could not be saved.');
}
});
app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`));
In a real application, populate req.user with your authentication middleware before the route. Check the user’s quota and destination authorization there. Never accept an object key or filesystem path from the browser.
4. Validate content, not just names
Uploaded content is untrusted. OWASP recommends an allowlist, server-side content validation, generated filenames, size limits, authorization, and storage outside the web root. See the OWASP File Upload Cheat Sheet and Input Validation Cheat Sheet.
- Allow only formats your product needs. Reject everything else.
- Do not trust the extension or the browser’s
Content-Type; inspect magic bytes and, where practical, decode the file with a format-aware library. - Generate a random object key. Preserve the original filename only as display metadata after normalizing it.
- Enforce per-file, per-request, per-user, and total-storage limits.
- For images, decode and rewrite accepted formats to remove unexpected payloads and metadata when appropriate. Derive the stored extension from detected content.
- Scan or sandbox documents and archives when the threat model requires it. For archives, limit both compressed and decompressed size and prevent path traversal during extraction.
- Apply CSRF protection to cookie-authenticated upload routes and rate-limit attempts.
5. Keep storage away from executable application content
Do not place user files in a directory where the web server can execute scripts. Use a separate storage volume, bucket, server, or domain. For private content, serve through an authorization route or short-lived signed URL. A browser preview does not require making the original upload public.
Store metadata separately from bytes: owner ID, generated object key, detected type, byte length, upload time, processing state, visibility, and deletion time. Your download handler should authorize the owner or an explicit sharing policy before returning bytes, then set a correct Content-Type and a safe Content-Disposition.
6. Move large uploads directly to object storage
For scalable uploads, the application should authenticate the user, decide the permitted key and size, then mint a short-lived upload URL or permission. The browser sends bytes directly to storage, keeping storage credentials out of client code. AWS documents this presigned URL pattern in its secure file transfer guidance.
- Client sends filename, intended type, and size to your application.
- Application authenticates the user, checks quota and policy, generates an object key, and creates a short-lived permission limited to that key and size.
- Client uploads directly to storage and reports completion.
- Application verifies the resulting object, scans or transforms it, and marks metadata as accepted.
- Downloads use authorization plus a short-lived read URL or controlled download route.
For multipart uploads, configure expiry and cleanup for abandoned parts. Keep the upload permission narrow: one object key, one method, an explicit content length where supported, and a short expiration.
7. Add image-specific processing
- Generate thumbnails asynchronously instead of making the upload request wait.
- Set a pixel-dimension limit as well as a byte limit; a small compressed image can expand to a very large bitmap.
- Strip or retain EXIF metadata deliberately. Location metadata can be sensitive.
- Use detected content to select the output extension and MIME type.
- Serve responsive variants rather than the original when a page only needs a preview.
- Keep originals private when users should not download them directly.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| 413 Payload Too Large | Proxy, framework, or application limit is below your stated limit. | Align limits at the reverse proxy, web server, framework, and storage layer. Keep a product-level limit lower than infrastructure maximums. |
| Every file is rejected | Client MIME type differs from your allowlist, or magic-byte detection fails. | Log detected type and declared type safely, support only formats you intend, and test real files from each browser. |
| Upload succeeds but download executes code | Files are inside an executable web root or served with unsafe handling. | Move storage outside the web root or to a separate bucket/domain and serve with controlled content types. |
| Temporary files fill disk | Interrupted requests and rejected files are not cleaned up. | Delete temporary files in every success and failure path and run a scheduled orphan cleanup. |
| Duplicate names overwrite files | The original filename is used as the storage key. | Generate a random server-side key and keep the original name only in metadata. |
| Private preview returns 403 | The preview URL has no authorized read path or has expired. | Use a controlled download endpoint or mint a fresh short-lived read URL after authorization. |
| Direct-to-storage upload fails with CORS errors | Bucket CORS does not allow the browser origin and method. | Configure the exact origins, methods, and headers required by your upload flow; do not use a wildcard for private authenticated systems without understanding its effect. |
| Large uploads time out | The application proxy is carrying all bytes or has a short request timeout. | Use direct-to-storage uploads and multipart transfer, then process asynchronously. |
| Archive extraction is unsafe | Entries contain path traversal or expand far beyond the compressed size. | Reject absolute and parent-directory paths and enforce decompressed-size and file-count limits before extraction. |
9. Performance, reliability, and cost
- Performance: stream bytes instead of buffering entire files in memory; use direct-to-storage transfer for large files; resize images asynchronously; serve cached derivatives.
- Reliability: make completion processing idempotent, record an explicit processing state, retry transient storage or scanner failures, and clean abandoned temporary and multipart data.
- Capacity: enforce quotas per user and globally. Monitor request rate, rejected bytes, storage growth, processing latency, and failed scans.
- Cost: account for storage, requests, bandwidth, scans, image transformations, database metadata, and backups. Compare current quotas and pricing before selecting a provider.
- Privacy: define retention and deletion behavior, protect private objects, and avoid exposing original filenames or image metadata unnecessarily.
10. Or skip the browser setup
ScreenshotNeo is useful when your upload workflow also needs reliable previews or snapshots of uploaded pages. Its API accepts one GET request and returns PNG, JPEG, WebP, or PDF. Before capture it accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be turned off.
Only clean shots are billed. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and the response identifies the result with X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
The Free plan includes 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots, and every feature is on every plan. Create a free ScreenshotNeo account.
11. Deployment checklist
- Authentication and authorization run on the server.
- Allowlist and content detection are enforced server-side.
- File, request, user, and storage quotas are configured.
- Storage keys are generated by the application.
- Uploads are outside the executable web root.
- Private downloads require authorization.
- Temporary files and abandoned multipart uploads are cleaned up.
- Images are decoded, resized, or rewritten according to policy.
- Archives have traversal and expansion limits.
- Rate limits, logging, monitoring, retention, and deletion are documented.
12. FAQ
Should I trust the file extension?
No. Treat it as display metadata only. Check content using magic bytes and format-aware decoding on the server.
Can I make uploaded files public for convenience?
Only when the product requires public files. For private uploads, use authorization plus a controlled route or short-lived signed access.
When should I use presigned URLs?
Use them when backend bandwidth, request duration, or file size makes proxying bytes through the application undesirable. Keep each permission short-lived and narrowly scoped.
Is browser validation enough for images?
No. Browser checks provide immediate feedback, while the server must enforce type, size, dimensions, authorization, and processing policy.
What should happen after an upload finishes?
Record metadata, verify the stored object, scan or transform it when required, mark its processing state, and expose it only through the intended access policy.


