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 accept reliably 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 accept as ergonomics, not enforcement.

Prerequisites

  1. An <input type="file"> in the DOM. accept is meaningless on any other input type and on a bare drop zone.
  2. TypeScript with lib: ["DOM", "ES2022"], or plain ESM — nothing here needs a build step beyond types.
  3. 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.

The three token forms in an accept list An accept string splits into an extension token matched on the file name, an exact MIME token matched on the resolved type, and a wildcard token expanded by the operating system MIME table. Three token forms in one accept list accept=".heic, image/jpeg, image/*" Extension token .heic Matched on the file name only. Case- insensitive. The leading dot is required. Exact MIME type image/jpeg Matched on the type the OS resolves. An empty type never matches anything. Wildcard subtype image/* Whole family, expanded from the OS MIME table, so its gaps become your gaps. None of the three survives past the moment the dialog closes.
Extension tokens and MIME tokens take different code paths inside the picker, which is why a mixed list is more robust than either alone.

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

  • parseAccept throws on a malformed token instead of ignoring it. The browser’s own parser is silent, so accept="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) turns image/* into the family key image. Storing the family rather than re-splitting on every file keeps the hot path to three Set lookups.
  • matchesAccept checks the extension before the type. That ordering is what makes the helper survive an empty file.type, which is the single most common cause of a legitimate file being rejected.
  • if (!type) return false after the extension check is deliberate: an empty type cannot satisfy a MIME token, so falling through to the wildcard test would call type.indexOf("/"), get -1, and produce the family "" — a subtle always-false that reads as a bug later.
  • input.accept = ACCEPT assigns 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 every change is not cosmetic. Without it, picking photo.jpg, cancelling the upload, and picking photo.jpg again fires no event at all, because the input’s value has not changed.
  • The drop handler reuses screen() verbatim. This is the point of the module: drag-and-drop file uploads never consult accept, and a dropped folder arrives via the DataTransfer API with contents the dialog would never have shown.
Two entry paths converge on one validator The picker path is filtered by accept but the user can switch to All Files, while the drop path never consults accept; both paths end at the same client-side validation function. Two entry paths, one gate click the file input drop onto the drop zone OS dialog pre-filters on accept accept is never consulted user picks "All Files" — filter off anything lands in dataTransfer screen(files) — ext, type, size Whatever the dialog allowed, the same function decides.
The picker filter and the drop path diverge completely, so the only durable rule is the one written in JavaScript.

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.

Client-side validation ladder for a selected file Four ordered checks — extension, reported type, magic bytes and size — each with the specific failure it catches. Run the cheap checks first check, in order what it catches 1 — extension in allowlist the picker filter was bypassed 2 — file.type in allowlist empty type on an odd extension 3 — magic bytes agree an .exe renamed to .png 4 — size within the cap a 413 after a long upload Only step 3 reads bytes, and only for files that passed steps 1 and 2.
Steps 1 and 2 are free string comparisons; step 3 is the first one that touches file content, and step 4 saves a wasted upload.

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.