File Upload Validation
File inputs break the usual validation model in three ways. First, the browser’s built-in constraints stop at required — there is no native maxsize, and the accept attribute is only a hint to the file picker, not a rule. Second, the interesting facts about a file (its real type, its pixel dimensions, whether it is corrupt) are only knowable by reading it, which is asynchronous. Third, users bring files through several doors — the picker, drag and drop, paste, and mobile camera capture — and each door must lead to the same checks. The result in many products is a form that happily uploads a 60 MB PNG renamed to .pdf for thirty seconds before the server rejects it with “Invalid file”. This topic fixes that by validating files in the browser the moment they are chosen, with the site’s standard <form novalidate> and Constraint Validation API baseline carrying every verdict through setCustomValidity() on the file input itself.
The failure mode this topic targets is specific: slow, late, vague rejections. Every check below runs before a single byte is uploaded, names the file and the rule it broke, and leaves the user with a clear next step.
Prerequisites for File Validation
| Requirement | Minimum version | Why it is needed |
|---|---|---|
| TypeScript | 5.0+ | Typed rule tables and discriminated result unions |
File.slice() + Blob.arrayBuffer() |
Chrome 76, Firefox 69, Safari 14 | Reading the first bytes without loading the whole file |
createImageBitmap() |
Chrome 50, Firefox 42, Safari 15 | Decoding images off the main thread for dimension checks |
DataTransfer constructor |
Chrome 60, Firefox 62, Safari 14.1 | Rebuilding an input’s FileList after removing a file |
input.files assignment |
All evergreen browsers | Putting dropped files into the real input so the form submits them |
| Server-side validation | — | Every rule here must be repeated on the server; the browser is advisory |
File Input API Reference
| API | Type | Returns / effect | Notes |
|---|---|---|---|
accept |
attribute | Filters the picker | Hint only — users can choose “All files” |
multiple |
boolean attribute | Allows several files | No native maximum count |
capture |
attribute | Opens the camera on mobile | user or environment |
input.files |
FileList |
Chosen files | Replace via DataTransfer to edit |
file.size |
number (bytes) |
Size on disk | Available instantly, no reading needed |
file.type |
string |
MIME type guessed from the extension | Empty for unknown extensions; never trust it |
file.slice(0, n).arrayBuffer() |
Promise<ArrayBuffer> |
First n bytes | Used for signature (“magic byte”) checks |
change event |
Event |
Fires after picking | Also fire it yourself after drops |
setCustomValidity() |
(msg) => void |
Marks the input invalid | Works on file inputs like any other |
Step-by-Step Implementation
1. Declare what you accept, for the picker’s sake
<form id="upload" novalidate>
<label for="docs">Supporting documents</label>
<p id="docs-hint" class="hint">PDF, PNG or JPEG. Up to 5 files, 10 MB each.</p>
<input id="docs" name="docs" type="file" multiple required
accept=".pdf,.png,.jpg,.jpeg,application/pdf,image/png,image/jpeg"
aria-describedby="docs-hint docs-err">
<ul id="docs-err" class="field-error-list" hidden></ul>
<button type="submit">Upload</button>
</form>
The hint states the rules in words before anyone picks a file. accept lists both extensions and MIME types because different platforms’ pickers honour different forms.
2. Describe the rules as data
export interface UploadRules {
maxFiles: number;
maxBytes: number;
extensions: string[]; // lowercase, with dot
signatures: Record<string, number[][]>; // MIME → accepted leading byte patterns
}
export const DOC_RULES: UploadRules = {
maxFiles: 5,
maxBytes: 10 * 1024 * 1024,
extensions: [".pdf", ".png", ".jpg", ".jpeg"],
signatures: {
"application/pdf": [[0x25, 0x50, 0x44, 0x46]], // %PDF
"image/png": [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
"image/jpeg": [[0xff, 0xd8, 0xff]],
},
};
3. Check each file, cheap checks first
export type FileVerdict = { file: File; error: string };
const fmtMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
async function sniff(file: File, rules: UploadRules): Promise<string | undefined> {
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
return Object.entries(rules.signatures).find(([, patterns]) =>
patterns.some((p) => p.every((byte, i) => head[i] === byte)),
)?.[0];
}
export async function checkFile(file: File, rules: UploadRules): Promise<FileVerdict> {
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
if (!rules.extensions.includes(ext)) {
return { file, error: `${file.name} is a ${ext || "file without an extension"}; choose a PDF, PNG or JPEG.` };
}
if (file.size === 0) return { file, error: `${file.name} is empty.` };
if (file.size > rules.maxBytes) {
return { file, error: `${file.name} is ${fmtMB(file.size)}; the limit is ${fmtMB(rules.maxBytes)}.` };
}
const detected = await sniff(file, rules);
if (!detected) return { file, error: `${file.name} doesn't look like a real PDF, PNG or JPEG file.` };
return { file, error: "" };
}
Extension and size come straight from the File object, so they cost nothing. The signature check reads twelve bytes, which is fast even for a gigabyte file because slice() never loads the rest. The details, including why file.type cannot be trusted, are in validating file type with magic bytes.
4. Aggregate verdicts onto the input
const input = document.querySelector<HTMLInputElement>("#docs")!;
const list = document.querySelector<HTMLUListElement>("#docs-err")!;
let pending: Promise<void> = Promise.resolve();
async function validateFiles(): Promise<void> {
const files = [...(input.files ?? [])];
const problems: string[] = [];
if (files.length > DOC_RULES.maxFiles) {
problems.push(`You chose ${files.length} files; the limit is ${DOC_RULES.maxFiles}.`);
}
const verdicts = await Promise.all(files.map((f) => checkFile(f, DOC_RULES)));
problems.push(...verdicts.filter((v) => v.error).map((v) => v.error));
input.setCustomValidity(problems[0] ?? ""); // first problem becomes the field message
list.replaceChildren(...problems.map((p) => Object.assign(document.createElement("li"), { textContent: p })));
list.hidden = problems.length === 0;
input.toggleAttribute("aria-invalid", problems.length > 0);
}
input.addEventListener("change", () => (pending = validateFiles()));
input.form!.addEventListener("submit", async (event) => {
event.preventDefault();
await pending; // never submit mid-check
if (!input.form!.reportValidity()) return;
input.form!.submit();
});
The pending promise closes a real race: a user who picks files and immediately presses Enter would otherwise submit before the asynchronous signature check finished. Waiting for it before calling reportValidity() keeps the canonical submit flow intact.
State Management and Edge Cases
A file input’s state is its FileList, and a FileList is read-only. That single fact shapes the implementation: to remove one bad file while keeping the good ones, you build a new list with DataTransfer and assign it back to input.files.
export function removeFile(input: HTMLInputElement, index: number): void {
const dt = new DataTransfer();
[...(input.files ?? [])].forEach((f, i) => i !== index && dt.items.add(f));
input.files = dt.files;
input.dispatchEvent(new Event("change", { bubbles: true })); // re-run validation
}
Other states to plan for:
- Re-picking replaces, it does not append. Opening the picker again discards the previous selection in every browser. If users should be able to add files in several rounds, keep your own array and rebuild
input.filesfrom it after each pick. - Drag and drop bypasses the picker. Dropped files never pass through
accept. Route them through the samecheckFileand into the same input, as shown in validating drag and drop file uploads. - Large images need decoding. Dimension rules require decoding the image, which can take hundreds of milliseconds for a 50-megapixel photo. Use
createImageBitmapso decoding stays off the main thread; the recipe is validating image dimensions client-side.
Accessibility Compliance for File Inputs
The native file input is one of the more accessible controls in HTML when left alone: it has a real button, announces the chosen file names, and works with keyboard and switch access. Most accessibility bugs come from hiding it behind a styled <div> with a click handler. If you restyle it, keep the input in the DOM, visually hidden but focusable, and label a visible <label> as the button.
Errors must name the file — “holiday.heic is a .heic; choose a PDF, PNG or JPEG” — because with several files a bare “invalid file type” leaves the user guessing which one to remove (WCAG 3.3.1 and 3.3.3). Render the per-file errors as a list referenced by aria-describedby, and let the first one become the input’s validationMessage so reportValidity() focuses the input and speaks something specific. Drop zones need a keyboard alternative: the zone can be an enhancement, but the underlying input must stay reachable by Tab (WCAG 2.1.1).
/* Visually hidden but still focusable and announced */
.file-input { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; }
.file-input:focus-visible + label { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
Progress feedback belongs to accessibility as well. Reading bytes and decoding images is usually instant, but a 40 MB photo on a low-end phone can take a second or two, and during that time the user needs to know something is happening. Put a short status message — “Checking 3 files…” — into a role="status" region when a check starts, and replace it with the outcome when it ends: “3 files ready to upload” or “2 of 3 files have problems”. That single polite announcement per selection is enough; announcing each file individually floods the speech queue when someone selects twenty photos at once. For uploads that start on selection, reuse the same region for upload progress milestones (started, halfway, done) rather than every percentage point.
Common Gotchas and Debugging
Trusting accept. It filters the picker’s default view; users switch to “All files” in two clicks, and drag and drop ignores it entirely.
Trusting file.type. It is derived from the extension by the operating system. Rename malware.exe to invoice.pdf and file.type says application/pdf.
// Before: trusts the extension-derived type
if (file.type !== "application/pdf") reject();
// After: checks the actual first bytes
if ((await sniff(file, DOC_RULES)) !== "application/pdf") reject();
Reading whole files for small checks. await file.arrayBuffer() on a 2 GB video to read four bytes allocates 2 GB. Always slice() first.
Clearing the input to “reset” errors. Setting input.value = "" removes the user’s files. Clear the custom validity and error list instead, and only remove the specific files the user chooses to remove.
Size limits in decimal versus binary megabytes. 10 MB in your copy, 10 * 1000 * 1000 in the client and 10 * 1024 * 1024 in the server config produce a band of files that pass the browser and fail the server. Define the limit once, in bytes, in a shared module, as described in enforcing file size limits before upload.
Uploading Only What Passed
Validation that runs before upload opens a better upload experience: upload each file as soon as it passes, in the background, while the user fills in the rest of the form. The form then submits references (upload IDs) rather than raw files, submission is instant, and a failed file can be retried alone. This changes the validation contract slightly — the form is valid when every required upload has completed, not merely been chosen — so track per-file status and set the input’s custom validity to “Wait for uploads to finish” while any are in flight. The server still validates each file on receipt, including a malware scan for anything users can later download.
Enforcing the Same Upload Rules on the Server
Every rule in this topic is a convenience in the browser and a requirement on the server. A request built with curl -F skips the picker, the accept filter, the size check and the signature sniff, so the server must re-derive all of them from the bytes it actually receives — and it must do so while receiving, not after, or an attacker can stream a 20 GB body into your temporary directory before any check runs. Most frameworks expose a per-request body limit and a per-file limit on their multipart parser; set both from the same shared constant the client uses, and reject with 413 Content Too Large as soon as the limit is crossed.
// shared/upload-rules.ts — imported by the browser bundle and the API
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
export const MAX_FILES = 5;
export const ALLOWED = ["application/pdf", "image/png", "image/jpeg"] as const;
// server/upload.ts (sketch using a streaming multipart parser)
export async function handleUpload(req: Request): Promise<Response> {
const form = await parseMultipart(req, { maxFileSize: MAX_FILE_BYTES, maxFiles: MAX_FILES });
const errors: Record<string, string> = {};
for (const [i, file] of form.files.entries()) {
const detected = sniffBytes(file.head); // same signature table as the client
if (!detected || !ALLOWED.includes(detected)) {
errors[`docs.${i}`] = `${file.name} doesn't look like a real PDF, PNG or JPEG file.`;
}
}
if (Object.keys(errors).length) return Response.json({ errors }, { status: 422 });
// store under a generated name, never the user-supplied one
return Response.json({ ok: true });
}
Three server-only duties have no client-side equivalent. Store files under generated names, never the uploaded file name, which can contain path separators and control characters. Serve user uploads from a separate origin or with Content-Disposition: attachment, so an uploaded HTML or SVG file cannot run script in your site’s origin. And scan anything other users will download. The error shape above — field-keyed messages in a 422 response — is the one the client can map straight back onto the file input, following handling 422 Unprocessable Content responses.
Testing File Validation End to End
File inputs are easy to test with Playwright because setInputFiles accepts in-memory buffers, so fixtures can be generated per test instead of committed as binaries. Build the three interesting cases — a real file, a renamed impostor, and an oversized file — and assert on the user-visible outcome: the named error message, aria-invalid, and whether submission was blocked.
import { test, expect } from "@playwright/test";
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
test("renamed and oversized files are rejected with named messages", async ({ page }) => {
await page.goto("/upload");
const input = page.getByLabel("Supporting documents");
await input.setInputFiles([
{ name: "scan.png", mimeType: "image/png", buffer: Buffer.concat([PNG_HEADER, Buffer.alloc(1024)]) },
{ name: "report.pdf", mimeType: "application/pdf", buffer: Buffer.from("MZ not really a pdf") },
{ name: "huge.jpg", mimeType: "image/jpeg", buffer: Buffer.alloc(11 * 1024 * 1024, 0xff) },
]);
const errors = page.locator("#docs-err li");
await expect(errors).toHaveCount(2);
await expect(errors.nth(0)).toContainText("report.pdf doesn't look like a real");
await expect(errors.nth(1)).toContainText("huge.jpg is 11.0 MB");
await expect(input).toHaveAttribute("aria-invalid", "true");
});
Generating an 11 MB buffer in a test is cheap, and it exercises the real size path rather than a mocked size property. For unit tests of checkFile itself, construct File objects directly — new File([bytes], "name.pdf") works in modern jsdom and in Node 20+ — and run the verdict function without any DOM at all. The broader strategy is laid out in testing form error messages with Playwright.
Browser Compatibility Matrix
| Feature | Chromium | Firefox | Safari | Fallback |
|---|---|---|---|---|
Blob.slice + arrayBuffer() |
76+ | 69+ | 14+ | FileReader.readAsArrayBuffer |
createImageBitmap(file) |
50+ | 42+ | 15+ | new Image() with an object URL |
DataTransfer() constructor |
60+ | 62+ | 14.1+ | Keep your own array, submit with FormData |
Assigning input.files |
Yes | Yes | Yes | — |
capture attribute |
Android | Android | iOS | Ignored on desktop |
| HEIC decoding | No | No | Yes | Reject or convert server-side |
HEIC deserves its own line: iPhones produce it by default, only Safari can decode it, and file.type is often empty for it on other platforms. Decide deliberately whether to accept HEIC (and convert it server-side) or to say clearly that it is not supported.
Frequently Asked Questions
Does the accept attribute validate file types?
No. It only filters the file picker's default view. Users can choose any file, and drag and drop ignores accept entirely, so check each file's extension and content in script and again on the server.
Can I trust file.type to know what a file is?
No. The browser derives it from the file extension, so renaming a file changes it. Read the first bytes with file.slice() and compare them to known signatures instead.
How do I show an error for a file input with the Constraint Validation API?
Call setCustomValidity() on the file input with a message that names the file and the rule. reportValidity() then focuses the input and announces the message like any other field.
Is client-side file validation enough?
No. It saves users time, but anyone can bypass it. The server must repeat every check, and files that others will download should also be scanned for malware.
Related Guides
- Validating File Type with Magic Bytes — content-based type detection.
- Enforcing File Size Limits Before Upload — per-file and total limits with clear messages.
- Validating Image Dimensions Client-Side — width, height and aspect ratio checks.
- Validating Drag and Drop File Uploads — one validation path for dropped files.
- Showing a Loading State During Form Submission — feedback while uploads complete.
← Back to Validating Common Input Types