For nine years, a web page could enumerate every MIDI device attached to your computer without asking you anything. Then Chrome 124 shipped and the Web MIDI permission prompt appeared on every site that touches the API. A lot of working code broke that week, and a lot of people still think the prompt is about sysex. It is not — sysex has always prompted. This is what the permission model protects, how each browser implements it differently, and how to build a flow that users actually approve.
- Chrome 124 (April 2024): the entire Web MIDI API is now permission-gated, not just sysex.
- Two risks: device enumeration is a fingerprinting signal; sysex can read and in principle write device firmware.
- Sysex is a separate, stronger grant. Refused sysex rejects the whole access promise.
- Firefox implements the grant as an auto-generated site-permission add-on, scoped per subdomain.
- Permissions Policy can veto it outright, and the user is never asked.
- Secure context only. Chromium removed Web MIDI from insecure origins entirely.
What is the permission actually protecting?
Two separate things that happen to live behind the same API: your device fingerprint, and the contents of your hardware.
The security and privacy section of the W3C spec names fingerprinting first. The reasoning is that a typical person has a small number of MIDI devices, the set changes rarely, and the names are specific — "Arturia KeyStep 37" plus "Elektron Digitakt" plus "IAC Driver Bus 1" is close to a unique identifier and it survives clearing cookies. The spec draws an explicit parallel with gamepad fingerprinting exposure, and the Gamepad spec has its own mitigation section for the same reason.
The second risk is sysex, and the spec spells out the attack in detail: a malicious page identifies a known-vulnerable USB MIDI device, then sends crafted System Exclusive messages that update its firmware. The spec is honest that this requires several conditions to line up — user-programmable firmware, firmware updates delivered over sysex, no physical confirmation step on the device, and access to the USB controller firmware. It is a theoretical chain, not a demonstrated exploit. But it is the reason implementations are told to inform users of firmware risks and restrict sysex access.
There is a quieter third risk the spec also mentions: sysex can read. Patch dumps, sample data and configuration all travel over sysex on a lot of hardware, and a page with sysex permission can request them. That is a data exfiltration path from a device that never imagined being on the internet.
What exactly changed in Chrome 124?
Chrome moved the whole API behind the prompt, so enumerating ports without sysex now requires an explicit grant.
The Chrome announcement went up on 16 April 2024 and states the change plainly: the entire Web MIDI API is now gated behind a permission prompt, rolling out gradually from Chrome 124. The tracking bug is Chromium issue 40063295, "Gate the entire Web MIDI API surface behind a permissions prompt", and the blog post notes the change aligns Chrome with the W3C Audio Working Group's position that explicit permission should be required before any MIDI use.
Chrome also bundles the two grants into one prompt: approving gives the site Web MIDI access, and if the site asked for sysex, sysex support along with it. That bundling is why the post advises requesting sysex only if your site absolutely needs it — the prompt string is more alarming when sysex is involved, and a scarier prompt is a less-approved prompt.
Separately, Chromium went through a deprecate-and-remove cycle for Web MIDI on insecure origins. The net effect today is that on an http:// origin other than localhost, navigator.requestMIDIAccess is simply not there — a different failure from being denied, and one your code should distinguish.
How do you check permission state without prompting?
Query the Permissions API with a midi descriptor, which resolves to granted, denied or prompt without showing anything.
This is the single highest-leverage thing you can do for your approval rate. If the state is already granted, connect silently. If it is prompt, show a button and explain what is about to happen. If it is denied, show recovery instructions instead of firing a request that will be auto-rejected.
// permission.js
export async function midiState({ sysex = false } = {}) {
if (!('requestMIDIAccess' in navigator)) return 'unsupported';
if (!navigator.permissions) return 'unknown';
try {
// The descriptor name is 'midi'; sysex is an extra member on it.
const status = await navigator.permissions.query({ name: 'midi', sysex });
return status.state; // 'granted' | 'denied' | 'prompt'
} catch {
// Firefox does not expose 'midi' to the Permissions API at all.
return 'unknown';
}
}
export async function watchMidiPermission(onChange) {
if (!navigator.permissions) return () => {};
try {
const status = await navigator.permissions.query({ name: 'midi' });
status.addEventListener('change', () => onChange(status.state));
onChange(status.state);
return () => status.removeEventListener('change', onChange);
} catch {
return () => {};
}
}
The change event on the PermissionStatus object is the part most people miss. If a user revokes MIDI access in site settings while your tab is open, this fires and you can tear the UI down gracefully instead of silently going dead.
How should the permission flow be designed?
Explain first, prompt on a click, and never ask on page load.
A browser permission prompt gives the user almost no context. All they see is "this site wants to control your MIDI devices", with no indication of why or what for. If that appears a quarter of a second after the page loads, the rational response is to dismiss it. If it appears immediately after they clicked a button labelled "Connect my controller", the rational response is to approve.
// connect-flow.js
import { midiState } from './permission.js';
export async function setupConnectUi({ button, status, onReady }) {
const state = await midiState();
if (state === 'unsupported') {
status.textContent = 'This browser cannot talk to MIDI devices. Try Chrome, Edge or Firefox on desktop.';
button.hidden = true;
return;
}
if (state === 'granted') {
const access = await navigator.requestMIDIAccess({ sysex: false });
button.hidden = true;
return onReady(access);
}
if (state === 'denied') {
status.textContent =
'MIDI access is blocked for this site. Open the padlock icon in the address bar and set MIDI to Allow.';
button.hidden = true;
return;
}
// state === 'prompt' or 'unknown'
status.textContent =
'We need MIDI access to see your controller. Nothing is sent anywhere - it stays in this tab.';
button.hidden = false;
button.addEventListener('click', async () => {
try {
const access = await navigator.requestMIDIAccess({ sysex: false });
onReady(access);
} catch (err) {
status.textContent = err.name === 'SecurityError'
? 'Permission was declined. Reload and try again if that was an accident.'
: 'MIDI could not be opened: ' + err.name;
}
}, { once: true });
}
The denied branch is worth writing properly. Once a user blocks a permission, the browser will not prompt again from JavaScript — you have to tell them where the setting lives. Naming the padlock icon is the most reliable instruction across Chrome, Edge and Firefox.
How do you handle the sysex upgrade?
Request basic access first, and only re-request with sysex when the user activates a feature that needs it.
The failure mode here is specific and unforgiving: if you ask for sysex and the user refuses, requestMIDIAccess rejects with NotAllowedError. You do not get a non-sysex access object as a fallback. So a site that asks for sysex up front, for a feature 95% of users will never touch, has made its core functionality contingent on the scariest prompt the API can produce.
// Two-tier access. Basic by default, sysex on demand.
let basicAccess = null;
let sysexAccess = null;
export async function getBasic() {
basicAccess ??= await navigator.requestMIDIAccess({ sysex: false });
return basicAccess;
}
export async function getSysex() {
if (sysexAccess) return sysexAccess;
try {
sysexAccess = await navigator.requestMIDIAccess({ sysex: true });
return sysexAccess;
} catch (err) {
if (err.name === 'NotAllowedError') {
throw new Error('Sysex was declined. Preset transfer is unavailable, but everything else still works.');
}
throw err;
}
}
// A sysex send with no permission throws InvalidAccessError, not a silent no-op.
export function trySendSysex(output, bytes) {
try {
output.send(bytes);
return true;
} catch (err) {
if (err.name === 'InvalidAccessError') return false; // no sysex grant
throw err;
}
} On the receive side the behaviour is quieter and more confusing: without the grant, sysex messages are filtered out of the input stream entirely. No error, no event, nothing. If you are debugging "my patch dump request never gets a reply", check your grant before you check your hex. The message formats themselves are in the sysex command deep dive.
Why does Firefox use an add-on instead of a prompt?
Because Mozilla chose to surface the fingerprinting cost rather than bury it in a one-click dialog.
Firefox's implementation landed via bug 1752906 and was enabled broadly through bug 1795025. Bug 1795927 captures the reasoning: the new site-permission add-on surface makes previously-silent Web MIDI fingerprinting visible to the user.
Mechanically, Firefox generates the add-on on the fly. The author does not have to publish anything; the user sees a one-time install prompt and the API works transparently afterwards. Three practical rules follow:
- The grant is scoped to the exact subdomain, so staging and production need separate approvals.
- Localhost is exempt, so you will not see the flow until you deploy.
- Firefox does not expose
midito the Permissions API, so yourquery()will throw. Handle it rather than assuming unsupported.
How does Permissions Policy override everything?
A Permissions Policy denial happens before the user is ever consulted, and no amount of clicking can undo it.
The midi directive has a default allowlist of self. Same-origin documents inherit it; cross-origin iframes do not. The Web MIDI spec is unambiguous that a feature disallowed by policy cannot be granted by user permission, which means a missing allow attribute produces a failure that looks like a denial but cannot be recovered from in the frame.
# Lock MIDI down to your own origin at the server level.
Permissions-Policy: midi=(self)
# Or delegate it to one trusted embed partner.
Permissions-Policy: midi=(self "https://partner.example")
If you publish an embeddable MIDI tool, this belongs in your integration docs in bold. Every integrator will forget it exactly once.
What should a privacy-respecting MIDI app actually do?
Ask for the least it can, keep the data local, and say so where the user can read it.
Concretely, five habits:
- Default to
sysex: false. Most apps never need it. Upgrade on demand. - Do not transmit port names anywhere. The device list is the fingerprint. If you have analytics, exclude it.
- Store mappings locally.
localStorageor a downloadable JSON file, not a server round trip. - Explain before prompting. One sentence next to the button beats any amount of post-hoc apology.
- Handle revocation live. Watch the
PermissionStatuschange event and degrade cleanly.
The visualiser at /play is built to that standard: it requests non-sysex access only, it reads input and never writes, everything stays in the tab, and the device name is remembered in local storage rather than sent anywhere.
Does any of this apply to a native bridge?
The threat model is different, because a native app is installed deliberately rather than loaded by a link.
The whole web permission apparatus exists because any page you visit can run code. An application you downloaded and installed has already cleared a much higher bar of user intent, so it does not need a per-session prompt to read a MIDI port. It also does not have the browser's constraints: it can create its own virtual port, keep running when no window is focused, and reach the HID surfaces WebHID only partially exposes.
That is not an argument that native is safer — it is an argument that they are different trust models. The browser's model is "assume hostile, prompt for everything". The native model is "the user chose to install this". Both are correct for their context, and the practical consequence is covered in browser versus native MIDI.
FAQ
Why does Chrome ask permission for MIDI now?
Chrome 124, announced in April 2024, moved the entire Web MIDI API behind a permission prompt. Before that only sysex access prompted and basic port enumeration was silent. The Chromium tracking issue is titled gate the entire Web MIDI API surface behind a permissions prompt.
What is the actual risk the permission protects against?
Two things. Enumerating MIDI ports is a fingerprinting signal, because the set of devices a person has attached is unusual and changes rarely. And sysex messages can read patches and samples out of a device, and on hardware that accepts firmware over MIDI could in principle write to it.
Is sysex a separate permission from basic MIDI access?
Yes. The MIDIOptions dictionary has a sysex member that defaults to false, and the specification requires implementations to treat sysex as a stronger grant. If sysex is requested and refused, requestMIDIAccess rejects with NotAllowedError rather than returning limited access.
What happens if I send sysex without permission?
The send call throws InvalidAccessError. On the input side the browser silently filters sysex messages out of the stream, so your handler never sees them and no error is raised.
Why does Firefox install an add-on instead of showing a prompt?
Firefox implements the Web MIDI grant as a site-permission add-on that the browser generates automatically. The install prompt is the permission prompt. The grant is scoped to the exact subdomain and localhost is exempt.
Can a Permissions-Policy header block MIDI entirely?
Yes, and the user cannot override it. The Web MIDI specification states that if the feature is disallowed by Permissions Policy the request fails without any user prompt at all. The midi directive defaults to an allowlist of self.
Does Web MIDI work over plain HTTP?
No. It is a secure-context API, so it is available on HTTPS origins and on localhost only. Chromium went through a formal deprecate-and-remove cycle for Web MIDI on insecure origins.
How do I check permission state without prompting?
Call navigator.permissions.query with a descriptor naming midi, optionally with sysex true. It resolves with a state of granted, denied or prompt. Firefox does not expose midi to the Permissions API, so handle the rejection.
The practical takeaway
Query first, prompt on a click, keep sysex optional, and your approval rate stops being a problem. Everything in this post is live on the visualiser if you want to see the flow before you build it, and the API mechanics underneath are in the complete Web MIDI guide.
If prompts are a dealbreaker — a teaching lab, a venue laptop, a locked-down studio machine — skip the browser entirely. Universal Controller MIDI runs as a native app on macOS, Windows and Linux with no permission dialog between the controller and your DAW. One controller, one virtual port and three saved presets on the free tier; AUD $89 once for Pro.