Secure File Uploads: Browser Checks, Server Validation and cURL Testing
An upload field looks simple: choose a photo, click Submit, and wait for a success message. But accepting bytes from users is not a single UI operation. It is a sequence of decisions about file type, identity, size, storage, processing and later downloads.
A good upload workflow is both pleasant to use and resistant to mistakes or malicious input. Browser checks improve feedback. Server checks establish the security boundary. Confusing the two is one of the most common upload design errors.
This guide explains how to structure that boundary, how to build correct multipart requests and how to test a route using cURL without assuming that a file extension tells the truth.
Start with a narrowly defined upload policy
Before writing the form, decide what the application actually accepts. For a profile photo feature, a useful policy may look like this:
| Decision | Example policy |
|---|---|
| Purpose | One profile image per authorized account |
| Accepted formats | JPEG and PNG only |
| Maximum upload size | 5 MiB per file |
| Maximum files | One per request |
| Filename in storage | Application-generated random identifier |
| Storage | Private object storage, not a public executable web directory |
| Processing | Decode, validate, re-encode and remove unnecessary metadata |
| Access | Only the owner and authorized staff can retrieve it |
The values are examples, not universal recommendations. A video editor, medical portal and avatar uploader need different policies. The important principle is to define why each file type is needed and remove everything else from the acceptance list.
The OWASP File Upload Cheat Sheet recommends layered controls including extension allowlists, content verification, generated storage names, quotas, authorization and separated storage.
Step 1: Give users early feedback in the browser
Start with an accessible input and a status message:
<form action="/api/profile/photo" method="post" enctype="multipart/form-data">
<label for="photo">Profile photo (JPEG or PNG, up to 5 MiB)</label>
<input id="photo" name="photo" type="file"
accept="image/jpeg,image/png" required>
<p id="photo-status" role="status" aria-live="polite"></p>
<button type="submit">Upload photo</button>
</form>
The accept attribute helps the system’s file picker suggest appropriate files. It is not a security filter. Browsers and operating systems may allow users to bypass it, and an attacker can always send an HTTP request without using your HTML form.
Add a small check so users do not waste time uploading an obviously oversized file:
const photo = document.querySelector("#photo");
const status = document.querySelector("#photo-status");
const maxBytes = 5 * 1024 * 1024;
const permittedTypes = new Set(["image/jpeg", "image/png"]);
photo.addEventListener("change", () => {
const file = photo.files?.[0];
status.textContent = "";
photo.setCustomValidity("");
if (!file) return;
if (file.size > maxBytes) {
photo.setCustomValidity("The file exceeds 5 MiB.");
} else if (!permittedTypes.has(file.type)) {
photo.setCustomValidity("Choose a JPEG or PNG image.");
}
if (!photo.checkValidity()) {
status.textContent = photo.validationMessage;
} else {
status.textContent = `Ready to upload: ${file.name}`;
}
});
These checks are convenience only. The browser’s file.type value, like the displayed filename, does not prove what the underlying bytes contain. Always repeat real validation on the server.
If you design a custom upload state or an error message, test the text and its color contrast. Color Studio helps choose accessible colors, while HTML Escaping vs Sanitizing explains why untrusted filenames should be displayed as text, not inserted with innerHTML.
Step 2: Send the file as multipart form data
A standard HTML form with enctype="multipart/form-data" sends one or more named parts. Each part contains metadata and bytes, separated by a boundary that the client generates.
For JavaScript-managed submission, use FormData:
const form = document.querySelector("form");
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
const body = new FormData(form);
const response = await fetch(form.action, {
method: "POST",
body,
credentials: "same-origin"
});
const status = document.querySelector("#photo-status");
status.textContent = response.ok
? "Photo uploaded successfully."
: "Upload failed. Review your file and try again.";
});
Do not manually set Content-Type: multipart/form-data when using FormData with fetch(). The browser must add the appropriate boundary parameter. Overriding the header without the matching boundary can make the server unable to parse the upload.
This example is intentionally small. Production code should also catch network exceptions, disable or guard against duplicate submissions when appropriate, and show specific safe error messages for rejected files. For authentication, include the application’s normal session and CSRF defenses; credentials: "same-origin" is not a substitute for CSRF protection.
For a comparison with JSON and URL-encoded bodies, see JSON vs Form Data vs Multipart: Send the Right HTTP Request with cURL.
Step 3: Validate on the server, not just in JavaScript
On the server, process each upload as untrusted input. A strong sequence is:
- Authenticate the user and authorize this upload action.
- Enforce a request-body limit before or during streaming. Reject oversized files early.
- Limit the number of parts and files to avoid resource exhaustion.
- Normalize and validate permitted extensions, but do not stop there.
- Examine the actual file signature and parse it with an appropriate maintained library; MIME headers can be forged.
- Apply format-specific validation and safe processing. For images, decode and re-encode the image, enforcing pixel and resource limits.
- Give the stored object a random server-generated name rather than trusting
../../avatar.phpor a confusing Unicode filename. - Store outside an executable webroot or use isolated object storage with explicit access controls.
- Return an opaque object identifier, not a predictable filesystem path.
- Record a sanitized audit event and handle failure cleanup without disclosing internal paths.
Even a valid JPEG can be huge in decoded pixel dimensions, and archive formats can expand dramatically. Limits must account for processing, not just uploaded compressed bytes. Consider antivirus scanning, content-disarm processing, quarantine and manual review according to the business risk.
Avoid claiming that a file is completely safe merely because it has a recognized signature. Signature checks are one layer; parsers themselves may have vulnerabilities.
The recent PHP security update guide is a reminder that safe application logic does not excuse an unpatched runtime. Keep the language runtime, image processors and upload libraries updated.
Step 4: Test the endpoint with cURL
A browser may reject a file before it ever reaches the server. To test server enforcement, send requests directly from your development machine or an authorized test environment:
curl --include --fail-with-body \
--request POST \
--url 'https://staging.example.com/api/profile/photo' \
--form 'photo=@./sample.png;type=image/png'
Use a disposable, non-sensitive test file. The --form flag builds a multipart request and includes a boundary. Add legitimate authentication for your staging environment rather than pasting secrets into shared logs or screenshots.
Then try controlled negative cases: a file larger than the limit, a renamed text file pretending to be a PNG, an unauthorized request, a second unexpected file, and a payload whose contents do not match its advertised MIME type. Record the status code and response body. Prefer clear, stable error formats, such as 413 for too-large requests, while recognizing that the precise status mapping is an application decision.
For other HTTP experiments, the free cURL Command Builder can prepare methods, headers and request bodies; it does not send the request. The cURL Command Guide explains shell quoting and command-line options. For multipart file attachments specifically, add --form in the terminal as shown above.
Never use --insecure merely to silence a TLS error during security testing. Fix or trust the correct test certificate instead.
Step 5: Understand CORS without weakening the endpoint
If your frontend and upload API are on different origins, the browser may perform a CORS preflight depending on the method, headers and body type. Cookies alone do not trigger a preflight; an Authorization header does. Credentialed requests also need an explicit allowed origin and the appropriate credentials response header.
CORS does not decide whether an upload is safe or authorized. It controls whether a browser makes a cross-origin response available to the calling script. A non-browser client can still call the endpoint. Continue enforcing identity, CSRF protections where applicable, allowed methods, quotas and content validation on the server.
A common debugging mistake is adding Access-Control-Allow-Origin: * to everything. That is incompatible with credentialed browser requests, and it can conceal a more important authorization mistake. Read the existing CORS preflight troubleshooting guide before changing access policies.
Use the browser Network panel to compare the preflight, upload request and response. Check which request failed rather than attributing every browser error to the file bytes.
Step 6: Protect uploaded files when users download them
Uploading is only half the lifecycle. Download endpoints should verify authorization independently, even when the browser already knows an object ID. Avoid handing out predictable public URLs for private documents. Set appropriate Content-Type and Content-Disposition response headers, and consider X-Content-Type-Options: nosniff where appropriate.
Do not serve arbitrary user-controlled HTML or SVG inline on the application’s trusted origin. Active content could execute in a security context you did not intend. An isolated download origin or attachment delivery policy helps reduce that risk. If a document needs a public URL, decide intentionally whether it is public, how it can be revoked and whether it should be indexed or cached.
For a client preview or test server, separately protect the environment itself. How to Protect an Apache Staging Site provides a focused starting point; HTTP Basic authentication around staging is an extra boundary, not a replacement for upload authorization.
A realistic upload test checklist
Test success and rejection paths before launch:
| Test | Expected outcome |
|---|---|
| Valid small PNG from authorized user | Stored with a generated identifier |
| File over size limit | Rejected with a clear error |
.png name containing unrelated bytes | Rejected after content inspection |
Wrong or spoofed Content-Type | Does not bypass server checks |
| Unauthenticated or unauthorized upload | Rejected before storage |
| Unexpected extra parts or files | Rejected or handled by an explicit policy |
| Private download requested by another user | Access denied |
| Interrupted upload or processing failure | Partial resources cleaned up |
A secure file upload pipeline is not a single regular expression. It is an agreement between the form, the HTTP request handler, the parser, the processing library, the storage system and the download endpoint.
Further reading
Try the workflow on an authorized staging API: Generate the basic request with the Web2Generators cURL Command Builder, add a test --form upload, and confirm that the server rejects invalid content even when browser-side checks are bypassed.