MQTT TLS and ACL Hardening for Sensor Fleets
An MQTT broker carrying environmental telemetry is an unusual security target: nobody wants to steal the temperature in a car park, but plenty of people would like to change it. Air quality figures feed planning decisions and regulatory reports, and a broker that accepts any authenticated client publishing to any topic lets a single leaked credential rewrite the network’s history. This guide hardens MQTT broker integration on two axes that matter more than any other: transport security that actually verifies the broker, and topic ACLs that bind each device to its own namespace.
Why Transport Security and Authorization Are Separate Problems
TLS answers “am I talking to the right broker, and can anyone read this?” ACLs answer “is this client allowed to write here?” Teams routinely solve the first and skip the second, which produces a network where every message is encrypted in transit and any device can publish as any other.
The asymmetry matters because the threat model for a sensor fleet is not eavesdropping. Telemetry is rarely confidential; the value is in its integrity and provenance. A node deployed on a public rooftop for three years is physically accessible, its flash is readable, and its credentials should be assumed extractable. The design question is therefore not how do I stop credentials leaking but what can an attacker do with one credential. With per-device certificates and a device-scoped ACL, the answer is: forge readings for that one sensor, which QC flagging and cross-device normalization will notice as a divergence from its neighbours. With a shared password and no ACL, the answer is: anything.
Production-Ready Implementation
Start with the topic structure, because the ACL can only be as precise as the topic hierarchy allows. Put the device identifier high in the topic, immediately after a fixed prefix:
env/v1// e.g. env/v1/eui-70b3d5-0021/pm25
env/v1//status device health, retained
cmd/v1/ downlink commands (device subscribes, never publishes)
That shape makes a per-device rule expressible as a single pattern. A hierarchy like
env/v1/pm25/<device_id> does not — you would need one rule per metric per device.
The Mosquitto ACL file then uses %c (the client id, taken from the certificate CN when
use_identity_as_username is set):
# /etc/mosquitto/acl.conf
# Devices: publish only under their own identifier, subscribe only to their own commands.
pattern write env/v1/%c/#
pattern read cmd/v1/%c
# The ingest service reads everything and writes nothing.
user ingest
topic read env/v1/#
# The command service writes downlinks and reads nothing.
user commander
topic write cmd/v1/#
Three properties of that file are worth stating explicitly. Devices have write and not read on
telemetry, so one compromised node cannot harvest the whole fleet’s data. The ingest service has
read only, so a bug in the consumer cannot republish and create a feedback loop. And the pattern
rules use %c rather than %u, tying authorization to the certificate identity rather than to a
username that could be shared.
The broker configuration that makes %c trustworthy:
# /etc/mosquitto/mosquitto.conf
listener 8883
protocol mqtt
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/broker.crt
keyfile /etc/mosquitto/certs/broker.key
require_certificate true # no client certificate, no connection
use_identity_as_username true # the certificate CN becomes %c / %u
tls_version tlsv1.2
allow_anonymous false
acl_file /etc/mosquitto/acl.conf
max_inflight_messages 100 # per-client QoS>0 window
max_queued_messages 2000 # bound the per-client backlog
require_certificate true is what turns the ACL from a suggestion into an enforced boundary: an
attacker without a signed client certificate never reaches the authorization stage at all.
On the Python side, the client must verify the chain properly. This is the function to copy:
# python 3.11 · paho-mqtt==2.1.0
import ssl
import paho.mqtt.client as mqtt
def build_client(
client_id: str,
ca_cert: str,
client_cert: str,
client_key: str,
*,
clean_session: bool = False,
) -> mqtt.Client:
"""An MQTT client that verifies the broker and identifies itself by certificate.
clean_session=False keeps the broker's QoS 1 queue for this client across a
reconnect, so an outage delays delivery instead of losing it.
"""
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
clean_session=clean_session,
protocol=mqtt.MQTTv311,
)
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_cert)
context.load_cert_chain(certfile=client_cert, keyfile=client_key)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.check_hostname = True # never disable this
context.verify_mode = ssl.CERT_REQUIRED
client.tls_set_context(context)
client.reconnect_delay_set(min_delay=1, max_delay=120)
return client
Building an SSLContext explicitly rather than calling client.tls_set(...) with keyword
arguments is deliberate: it makes hostname checking and the minimum protocol version visible in the
code, where a reviewer can see them, instead of relying on library defaults that have changed
between releases.
Parameter Tuning Guide
| Setting | Development | Field fleet | Why |
|---|---|---|---|
| Client certificate validity | 90 days | 2–3 years | field visits are expensive; rotate with firmware |
keepalive |
60 s | 120–300 s | longer keepalive saves cellular wake-ups |
reconnect_delay_set max |
30 s | 120 s | avoids a fleet-wide reconnect storm after an outage |
max_queued_messages |
100 | 2 000–10 000 | sets how long an offline subscriber is covered |
max_inflight_messages |
20 | 100 | QoS 1 window; higher costs broker memory per client |
| CRL refresh | on demand | daily | revocation is worthless if the broker never rereads it |
The certificate validity row is where security advice collides with field reality. A 90-day certificate is excellent practice and a guarantee of failure on a solar-powered node in a remote catchment: it will expire during the winter when nobody can reach it. Match validity to your realistic maintenance interval and rely on revocation for compromise, not on short lifetimes.
Verification and Testing
Two tests, both of which should be part of the deployment pipeline rather than a one-off check.
# python 3.11 · paho-mqtt==2.1.0 · pytest==8.2.0
import pytest
def test_broker_rejects_publish_outside_own_namespace(device_client):
"""A device must not be able to publish as a different device."""
info = device_client.publish("env/v1/eui-somebody-else/pm25", "42", qos=1)
info.wait_for_publish(timeout=5)
# Mosquitto silently drops ACL-denied publishes; assert the message never arrives
assert not received_on("env/v1/eui-somebody-else/pm25", timeout=2)
def test_client_refuses_a_broker_with_the_wrong_hostname(bad_hostname_broker):
client = build_client("test-01", CA, CERT, KEY)
with pytest.raises(ssl.SSLCertVerificationError):
client.connect(bad_hostname_broker.host, 8883)
The first test is the one that catches the most consequential misconfiguration, and it needs care: Mosquitto does not reject an ACL-denied publish with an error — it accepts the packet and discards the message. Asserting on the absence of the message downstream is the only reliable check, which is also a reminder that a device with a broken ACL will look perfectly healthy from its own logs.
Beyond tests, monitor the broker’s $SYS/broker/clients/connected against your expected fleet size
and alert on authentication failures per hour. A device whose certificate has expired disappears
silently, and a silent disappearance is indistinguishable from a dead sensor until someone checks.
Gotchas
tls_insecure_set(True) in a code path that reaches production. It is added to get past a
self-signed certificate in development and then survives review because the connection works. Grep
for it in CI and fail the build.
The CA bundle bundled with the device image goes stale. If you rotate your internal CA, every device holding the old bundle stops connecting. Ship the new CA alongside the old one for a full rotation period before retiring the old root.
Client id and certificate CN drifting apart. With use_identity_as_username true, the ACL
matches on the certificate identity — but the client id is what the broker uses for session state.
If a device connects with a client id that differs from its CN, its persistent session and its ACL
scope refer to different names, and the resulting behaviour is confusing rather than secure. Derive
both from the same device identifier.
Revocation without a CRL refresh. Adding a certificate to a revocation list has no effect until the broker rereads it, which for Mosquitto means a reload. A revoked device that keeps publishing for a month is the normal outcome of skipping that step.
FAQ
Do I need client certificates, or is username and password enough?
Passwords are adequate for a handful of gateways you control and inadequate for a fleet. A shared password is one leak away from anyone publishing to your topics, and rotating it means touching every device at once. Per-device certificates let you revoke exactly one sensor, and they carry the device identity into the ACL so authorization and authentication use the same fact.
What breaks if I set tls_insecure_set(True) to get past a certificate error?
Everything the certificate was protecting. That call disables hostname verification, so any certificate signed by any CA in the trust store is accepted — including one from an attacker who can redirect your DNS. It is a debugging tool, not a configuration option; if verification fails, the fix is a correct CA bundle or a certificate whose subject matches the broker hostname.
How do ACLs stop a compromised sensor from corrupting the dataset?
A topic ACL restricts each client to publishing under its own device identifier, so a compromised node can forge only its own readings. Without one, any authenticated client can publish to any topic, which means one stolen credential can inject fabricated measurements attributed to every other sensor in the network — and nothing downstream can tell the difference.
Related
- MQTT Broker Integration for Environmental Sensors — the broker integration this hardening applies to
- How to Sync MQTT Sensor Data to PostGIS with Python — the subscriber whose connection settings this guide secures
- MQTT QoS Levels and Duplicate Sensor Messages — the delivery-guarantee half of the same client configuration