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.

What one leaked credential can do, by configuration Matrix of four broker configurations against what an attacker holding one device credential can publish, what they can read, and how the credential is revoked. What one leaked credential can do, by configuration Can publish as Can read Revocation Shared password, no ACL any sensor the whole fleet rotate everywhere Per-device password, no ACL any sensor the whole fleet one password Per-device password + ACL itself only its own commands one password Client certificate + ACL itself only its own commands CRL entry
A rooftop node is physically reachable for years, so plan for the credential to leak and bound what it unlocks.

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.

What each side proves during the TLS handshake Flow diagram of the mutual-TLS handshake: the broker presents its certificate and the client verifies the chain and hostname, the client presents its certificate and the broker verifies it against the CA and revocation list, then the certificate identity becomes the ACL subject. What each side proves during the TLS handshake Broker cert client verifies chain and hostname Client cert broker verifies CA and revocation Identity CN becomes %c use_identity_as_username ACL applied env/v1/%c/# write own topics only tls_insecure_set(True) removes the hostname check in step one, which makes every later step meaningless.
Authorization is only as trustworthy as the identity it keys on, which is why the ACL subject comes from the certificate rather than from a client-supplied name.

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.

A certificate lifecycle that survives a field deployment Timeline of a client certificate over three years: issue, mid-life renewal window, a warning threshold that leaves time for a field visit, and expiry — with the point at which a short-lived certificate would already have stranded the node. A certificate lifecycle that survives a field deployment Issued 3-year validity 90-day cert expires node offline, unreachable Renewal window opens bundle with firmware Warning threshold 90 days left — schedule a visit Expiry connection refused months since issue
Short certificate lifetimes are excellent practice and a guarantee of winter outages on solar nodes. Match validity to the real maintenance interval and rely on revocation for compromise.

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.