Buried in the release notes of Zigbee2MQTT 2.11.0, one line stopped me mid-scroll: “External extensions and external converters are now disabled by default for new installations.” My install predates the change, so nothing broke. Nothing even flickered. But I sat there with my coffee thinking about the JavaScript files I’d pasted into that folder over the years — code I’d copied from GitHub gists and forum posts, written by strangers, running with full access inside the process that controls every Zigbee device in my house.
So this week’s post is half news, half confession, and half checklist. Yes, that’s three halves. It was that kind of audit.
What actually changed
Since version 2.11.0 — and still the case in the current 2.12.0 release from June — Zigbee2MQTT ships with external converters and external extensions off by default on new installations. If you need them, you now opt in explicitly with enable_external_js: true under the advanced section of your configuration. The change was contributed by Nerivec, who has been doing a lot of quiet hardening work on the project lately — the frontend also now refuses to save external JS files with invalid names.
Existing installations are untouched. If your converters were working last month, they still work today. This is purely a change in what a fresh install trusts out of the box — which is exactly how breaking changes should be done, and the project deserves credit for it.
Why this was overdue
The official security documentation puts it plainly: external converters “execute arbitrary user-provided JavaScript code within the Zigbee2MQTT process,” and malicious or buggy code “can compromise the entire Zigbee2MQTT instance, and potentially the host system.”
Think about what lives in that process. Your Zigbee network key. Your MQTT credentials. A direct line to every lock, relay, and sensor in the house. And the culture around external converters has always been wonderfully casual: you buy some obscure Tuya sensor for twelve francs, it pairs as “unsupported,” someone on a forum says “just drop this converter in,” and you do, because you want the thing working before dinner. I have done exactly this. More than once. The code was probably fine. “Probably fine” is not a security model.

The audit one changelog line triggered
After last week’s USB humiliation, my faith in “it’s been fine for years” is at an all-time low. So I SSH’d into the EliteDesk and actually looked at my external_converters folder. Three files. Two of them were for devices that gained official support ages ago — the docs even recommend this lifecycle: submit your converter upstream as a pull request, then delete the local copy once it ships in a release. I’d done the first part and skipped the second. Both deleted. The third, for a genuinely unsupported presence sensor, I actually read line by line for the first time since installing it. It’s clean. Now I know that, instead of assuming it.
While I was in there, I worked through the rest of the project’s securing guide, and I’d encourage you to do the same, because some of these defaults are sneakier than the converter one:
The network key. If your network is old enough, it may still be using the historical default key — the docs specifically call out [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13] and strongly suggest changing it. A default encryption key is a published encryption key. The fix is setting network_key: GENERATE, and the price is re-pairing every single device, which is why so many of us keep putting it off. Mine was already randomized from a rebuild two years ago; if yours isn’t, this is the least fun and most important item on the list.
The frontend. By default it listens on 0.0.0.0 — all interfaces — and anyone who can reach it has full control, same as MQTT. Mine is now bound to localhost, with an auth_token stored in secret.yaml rather than the main config. If you only ever open the frontend through Home Assistant on the same box, there is no reason for it to be reachable from your whole LAN.
File permissions. configuration.yaml contains your MQTT credentials and network key in plain text. chmod 600 on it and on secret.yaml took ten seconds.
Joining habits. Permit-join is temporary by default (254 seconds), but I’m the sort of person who opens it, gets distracted by a compile error, and leaves it. Closing the window manually after pairing is now a habit. For devices that ship with install codes — increasingly common on newer gear — use them; they encrypt the initial key handover to the joining device.
OTA sources. Zigbee2MQTT pulls firmware from the curated Koenkk/zigbee-OTA repository by default. Leave it that way unless you have a very good reason. A custom OTA index URL is a supply chain you’re choosing to trust with your door locks.
What I’d do this weekend, in order
If you run Zigbee2MQTT, here’s the priority list as I see it. First, open your external_converters folder and delete anything for a device that’s since gained official support — check the supported devices list, you’ll probably be surprised. Second, read what remains; it’s usually under a hundred lines. Third, check whether you’re on the default network key, and if you are, schedule the re-pairing weekend and just eat the cost. Fourth, bind the frontend to localhost and add a token. None of this requires new hardware or breaks anything that works.
And if you’re setting up a fresh instance: leave enable_external_js off until the day you actually need it. That’s the default now, and it’s the right one.
There’s a comfortable myth in this hobby that “local-first” automatically means “secure.” It doesn’t. It means the vendor’s cloud isn’t your problem — and everything else is. The Zigbee2MQTT project just made the everything-else slightly smaller for new users, and gave the rest of us a well-timed nudge to look at what we’ve been running. Take the nudge.

Leave a Reply