Restricting Uploads with the accept Attribute
accept is a hint to the operating system’s file dialog — it pre-filters what the user sees, it never blocks a selection, and it is ignored entirely on the drag-and-drop path, so every file that reaches your change handler still needs checking in JavaScript.
That single sentence is the whole story, but the details are where production bugs live: which token forms each picker honours, why a HEIC shot on an Android phone can be invisible under accept="image/*", and why file.type comes back as the empty string often enough to break a naive allowlist. This article sits under file type detection in the browser within upload fundamentals and browser APIs.
When to use this approach
- You want the picker to open on the right files so users are not scrolling past 400 PDFs to find a JPEG. That is a UX win worth having and the only thing
acceptreliably buys you. - You are pairing it with a real check: an extension and type screen on
change, magic-byte sniffing for anything you will decode, and server-side file validation as the actual boundary. - You are not using it as a security control. An attacker never opens your picker; they POST straight to your endpoint. Treat
acceptas ergonomics, not enforcement.
Prerequisites
- An
<input type="file">in the DOM.acceptis meaningless on any other input type and on a bare drop zone. - TypeScript with
lib: ["DOM", "ES2022"], or plain ESM — nothing here needs a build step beyond types. - A server-side allowlist already in place. If it is not, build that first; the client work below is a courtesy layer on top of it.
The three token forms
The accept attribute is a comma-separated list. Each token is one of exactly three shapes, and they are matched by completely different mechanisms:
<input
type="file"
id="photo"
multiple
accept=".heic,.heif,image/jpeg,image/png,image/webp"
/>
An extension token starts with a dot (.heic) and is matched against the file name, ASCII case-insensitively — IMG_0001.HEIC matches .heic. An exact MIME token (image/jpeg) is matched against the type string the OS resolves for that file. A wildcard token (image/*, audio/*, video/*) matches every type in that top-level family, expanded through the operating system’s MIME database rather than any list the browser owns.
Mixing forms is deliberate. .heic,image/heic covers both an OS that knows the type and one that only knows the extension; listing just one leaves a hole on some platform. The cost is a longer dropdown label in the Windows dialog, which nobody reads.
How each picker interprets the list
The browser hands the token list to a native dialog and loses control of it. Behaviour therefore tracks the OS, not the browser engine.
| Picker | .ext token |
type/subtype |
type/* |
Escape hatch |
|---|---|---|---|---|
| Windows common dialog (Chrome, Edge, Firefox) | Becomes a *.ext filter |
Mapped to extensions via the registry | Expanded to every registered extension in the family | “All Files (*.*)” in the type dropdown |
| macOS NSOpenPanel (Safari, Chrome) | Non-matching files greyed out | Resolved through Uniform Type Identifiers | Resolved through the UTI conformance tree | Type a path in ⌘⇧G, or drag a file into the panel |
| GTK dialog (Firefox on Linux) | Literal glob filter | Mapped via shared-mime-info | Expanded via shared-mime-info | “All Files” filter entry |
| Android document picker (Chrome) | Ignored by many providers | Honoured | Expanded via the provider’s MIME map | “Browse” into a provider that reports */* |
| iOS Safari | Mapped to UTIs | Mapped to UTIs | Adds Photo Library and Take Photo actions | “Browse” tab shows all documents |
Two lines of that table cause most support tickets. Android’s document picker frequently ignores bare extension tokens because content providers surface a MIME type, not a filename — so accept=".heic" alone can grey out the entire camera roll. And every row has an escape hatch, because the dialog belongs to the user’s operating system, not to your page.
Implementation
One module parses the attribute once and reuses the parsed rule for both entry paths. The same screen() function guards the picker’s change event and the drop handler, so the drag path cannot slip past the rules the picker enforced.
// accept-guard.ts — parse an accept list once, then enforce it on every File.
export interface AcceptRule {
extensions: Set<string>; // ".heic"
exactTypes: Set<string>; // "image/jpeg"
wildcards: Set<string>; // "image", from "image/*"
}
export function parseAccept(accept: string): AcceptRule {
const rule: AcceptRule = {
extensions: new Set(),
exactTypes: new Set(),
wildcards: new Set(),
};
for (const raw of accept.split(",")) {
const token = raw.trim().toLowerCase();
if (!token) continue;
if (token.startsWith(".")) rule.extensions.add(token);
else if (token.endsWith("/*")) rule.wildcards.add(token.slice(0, -2));
else if (token.includes("/")) rule.exactTypes.add(token);
// Fail loudly at boot: the browser would silently drop "jpg" or "image/jpg".
else throw new Error(`accept token "${raw.trim()}" is neither .ext nor type/subtype`);
}
return rule;
}
export function extensionOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot).toLowerCase() : "";
}
export function matchesAccept(file: File, rule: AcceptRule): boolean {
// Extension first: it is the only signal that survives an empty file.type.
const ext = extensionOf(file.name);
if (ext && rule.extensions.has(ext)) return true;
const type = file.type.toLowerCase();
if (!type) return false;
if (rule.exactTypes.has(type)) return true;
return rule.wildcards.has(type.slice(0, type.indexOf("/")));
}
const ACCEPT = ".heic,.heif,image/heic,image/jpeg,image/png,image/webp";
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
const rule = parseAccept(ACCEPT);
export interface Rejection {
file: File;
reason: string;
}
export function screen(files: FileList | File[]): { ok: File[]; rejected: Rejection[] } {
const ok: File[] = [];
const rejected: Rejection[] = [];
for (const file of Array.from(files)) {
if (!matchesAccept(file, rule)) {
rejected.push({
file,
reason: `"${file.name}" (${file.type || "unknown type"}) is not an accepted image`,
});
} else if (file.size > MAX_BYTES) {
const mb = (file.size / 1_048_576).toFixed(1);
rejected.push({ file, reason: `"${file.name}" is ${mb} MB, over the 25 MB limit` });
} else {
ok.push(file);
}
}
return { ok, rejected };
}
async function queue(files: File[]): Promise<void> {
for (const file of files) {
console.log(`queued ${file.name} — ${file.size} bytes — ${file.type || "no type"}`);
}
}
function report(rejected: Rejection[]): void {
const list = document.querySelector<HTMLElement>("#upload-errors");
if (list) list.textContent = rejected.map((r) => r.reason).join("\n");
}
const input = document.querySelector<HTMLInputElement>("#photo");
if (input) {
input.accept = ACCEPT; // keep attribute and rule from drifting apart
input.addEventListener("change", () => {
const { ok, rejected } = screen(input.files ?? []);
report(rejected);
input.value = ""; // re-picking the same file fires change again only after a reset
void queue(ok);
});
}
const zone = document.querySelector<HTMLElement>("#drop");
if (zone) {
zone.addEventListener("dragover", (e) => e.preventDefault());
zone.addEventListener("drop", (e) => {
e.preventDefault();
// accept was never consulted here — the same screen() call is the only gate.
const { ok, rejected } = screen(e.dataTransfer?.files ?? []);
report(rejected);
void queue(ok);
});
}
Line-by-line on the parts that matter
parseAcceptthrows on a malformed token instead of ignoring it. The browser’s own parser is silent, soaccept="jpg"ships to production as a filter that matches nothing; a boot-time throw catches it in the first dev run.token.slice(0, -2)turnsimage/*into the family keyimage. Storing the family rather than re-splitting on every file keeps the hot path to threeSetlookups.matchesAcceptchecks the extension before the type. That ordering is what makes the helper survive an emptyfile.type, which is the single most common cause of a legitimate file being rejected.if (!type) return falseafter the extension check is deliberate: an empty type cannot satisfy a MIME token, so falling through to the wildcard test would calltype.indexOf("/"), get-1, and produce the family""— a subtle always-false that reads as a bug later.input.accept = ACCEPTassigns the same constant the rule was parsed from. Hard-coding the string in the HTML and again in the module is how the two drift; one source wins.input.value = ""after everychangeis not cosmetic. Without it, pickingphoto.jpg, cancelling the upload, and pickingphoto.jpgagain fires no event at all, because the input’s value has not changed.- The
drophandler reusesscreen()verbatim. This is the point of the module: drag-and-drop file uploads never consultaccept, and a dropped folder arrives via the DataTransfer API with contents the dialog would never have shown.
Camera capture on mobile
capture is the companion attribute nobody documents properly. It is an enumerated attribute with two meaningful values, and it only does anything alongside an accept list that includes a media family:
<!-- Rear camera, still photo. Falls back to a normal picker on desktop. -->
<input type="file" id="photo" accept="image/*" capture="environment" />
<!-- Front camera, video recording rather than a still. -->
<input type="file" id="selfie-clip" accept="video/*" capture="user" />
capture="environment" requests the rear camera, capture="user" the front one, and a bare capture means “any camera”. Desktop browsers ignore the attribute entirely and open the ordinary dialog, which is the correct fallback and needs no feature detection.
The behaviour worth knowing: on Android Chrome, the presence of capture removes the gallery option — tapping the input launches the camera app directly with no way back to existing photos. iOS Safari is softer; it opens the camera but the user can still cancel out to the sheet. If your users need both “take a photo now” and “pick an old one”, ship two inputs, not one input with capture. A camera-captured JPEG also arrives at full sensor resolution, frequently 8–12 MB, so pair capture with the downscaling described in optimizing payload size for mobile uploads.
Configuration gotchas
Extension tokens without a leading dot are silently dropped. accept="jpg,png" is not a parse error — the browser discards both tokens (no dot, no slash) and you are left with a filter that matches nothing. On Windows the dropdown reads “Custom Files” and the folder appears empty; users conclude the upload is broken. The same trap catches accept="image", accept="*.png" and accept="image/jpg" — there is no image/jpg media type, only image/jpeg. This is precisely what the throw in parseAccept exists to surface.
An unknown extension yields an empty File.type. The browser does not sniff; it asks the OS, which answers from the Windows registry, macOS UTIs, or Android’s MIME map. For .heic, .avif, .mkv, .dng or any in-house extension the answer is often nothing, and file.type === "". That empty string does not vanish quietly — it becomes a wrong header on the wire, because FormData substitutes a default:
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="IMG_0001.HEIC"
Content-Type: application/octet-stream
Any backend allowlist keyed on that part header now sees a generic binary and answers 415 Unsupported Media Type, even though the picker offered the file and your extension check passed it. Re-wrap the file with a type you determined yourself before appending it — form.append("file", new File([file], file.name, { type: "image/heic" })) — and the part header carries the real value. The anatomy of that header is covered in multipart form data explained; if you are uploading straight to object storage instead, the same empty string breaks the signed Content-Type in S3 presigned URL workflows.
accept="image/*" hides HEIC on some Android builds. Android expands the wildcard through the MIME map owned by whichever document provider is serving the picker. On several OEM gallery apps, and on older builds where the provider reports HEIC as application/octet-stream, the phone’s own camera roll is greyed out — the device shot the file and then refuses to offer it back. Widen the list to accept="image/*,.heic,.heif,image/heic,image/heif". Then make sure the backend can actually decode it, or you have merely moved the failure from the picker into your thumbnailer — an ImageMagick build without the HEIF delegate fails with:
convert: no decode delegate for this image format `HEIC' @ error/constitute.c/ReadImage/746
The filter is one click from off, and nothing tells you. The Windows type dropdown ends in “All Files (*.*)”; macOS lets a user drag any file into the open panel. The resulting change event is byte-for-byte identical to a compliant selection — there is no flag, no validity state, nothing. The first honest signal is your own rejection message, or a 415 Unsupported Media Type from the origin after you skipped the client check. Assume every request has bypassed the filter, because eventually one will.
Where accept sits in the validation ladder
Run the cheap checks first and the expensive ones only on survivors. Extension and type comparison are string operations over data you already hold; reading the first bytes costs an async Blob.slice().arrayBuffer(), and the server round trip costs the most of all.
Steps 1 and 2 are what this page implements. Step 3 belongs to the sniffing helper, and both are advisory — the reason browser MIME types are unreliable is exactly why the authoritative pass runs in your handler, ideally with libmagic in Node.js or an equivalent.
Verification
Assert the matcher against the three cases that actually break in the field — an uppercase extension, an empty type, and an executable:
import { parseAccept, matchesAccept } from "./accept-guard.js";
const rule = parseAccept(".heic,image/jpeg,image/*");
const jpg = new File([new Uint8Array(4)], "Holiday.JPG", { type: "image/jpeg" });
const heic = new File([new Uint8Array(4)], "IMG_0001.HEIC", { type: "" });
const exe = new File([new Uint8Array(4)], "setup.exe", { type: "application/x-msdownload" });
console.assert(matchesAccept(jpg, rule), "uppercase extension must still match");
console.assert(matchesAccept(heic, rule), "empty type must fall back to the extension");
console.assert(!matchesAccept(exe, rule), "executables must be rejected");
console.assert(
parseAccept("image/*").wildcards.has("image"),
"wildcard family must be stored without the /*",
);
console.log("accept guard verified");
Then confirm what the OS reports for a real file. Pick one in the browser and read the values straight off the input — this is the fastest way to discover an empty type on a target device:
document.querySelector("#photo").addEventListener("change", (e) => {
for (const f of e.target.files) console.table({ name: f.name, type: f.type, size: f.size });
});
Finally, prove the server does not trust any of it:
curl -i -X POST https://api.example.com/uploads \
-F "file=@setup.exe;type=image/png"
# Expect: HTTP/1.1 415 Unsupported Media Type
If that curl returns 201, your accept attribute is the only thing standing between you and arbitrary file storage, and it is not standing anywhere.
Frequently Asked Questions
Can accept block a user from selecting the wrong file type?
No. It changes the default filter in the OS dialog, and every dialog offers a way past it — “All Files” on Windows, dragging into the panel on macOS, a different document provider on Android. Selection still succeeds and change still fires, so the screen() call is what actually rejects the file.
Why is file.type empty for some files?
Because the browser asks the operating system rather than reading the bytes. If the extension is not in the Windows registry, the macOS UTI database, or Android’s MIME map, the OS returns nothing and the File exposes "". Match on the extension as a fallback, and set an explicit type when re-wrapping the file for upload.
Does accept work with drag and drop?
Not at all. Dropped files arrive through DataTransfer.files, which never consults the input’s attribute — even if the drop target is the file input itself. Route both paths through the same validation function or the drop zone becomes an open door.
Should I use accept=“image/*” or list extensions explicitly?
List both. The wildcard is expanded by the OS MIME table, so any format that table does not know is invisible; explicit .heic,.heif tokens patch those holes. The one combination to avoid is extensions alone, because Android’s document picker frequently ignores them.
What does the capture attribute do on a desktop browser?
Nothing — it is ignored and the ordinary file dialog opens, so it is safe to ship in shared markup. On Android Chrome it launches the camera and removes the gallery option entirely, which is why a camera-only input should never be your only upload control.