Mosquitto mTLS Tutorial: Per-Device Client Certificates with a Private CA
Most MQTT tutorials stop at a username and a password. That is fine for one broker and one script, and it stops being fine the moment you have a fleet. A shared credential ends up baked into a firmware image, which means it is in your build artifacts, in whatever laptop flashed the device, and — eventually — in a support ticket. Rotating it means reflashing everything. Revoking one device means revoking all of them.
Mutual TLS solves this properly: every device gets its own certificate, signed by a CA you control. The broker verifies it cryptographically, and the certificate's Common Name becomes the MQTT username, so your access-control rules are written against a cryptographic identity rather than a parallel password database.
This tutorial builds that from an empty machine: a Mosquitto broker, a private CA, a broker certificate, per-device client certificates, and ACLs that confine each device to its own topics. Everything here is a real, runnable configuration.
What you will end up with
One CA, one broker certificate, one certificate per device. The CN carries the identity all the way into the ACL.
Prerequisites
- A Linux host (or container) for the broker. Commands below assume Debian/Ubuntu.
mosquittoandmosquitto-clients.- The
stepCLI andstep-cafor the private CA. You can do all of this with rawopenssl, but step-ca gets you a correct root/intermediate hierarchy without hand-writing extension files.
sudo apt-get update
sudo apt-get install -y mosquitto mosquitto-clientsStop the packaged service while you write your own config — the default unit ships a listener you do not want:
sudo systemctl stop mosquittoStep 1: a baseline broker
Start with the smallest config that runs, so you have something working before adding cryptography. Put this in /etc/mosquitto/mosquitto.conf:
persistence true
persistence_location /var/lib/mosquitto/
log_dest stdout
log_type notice
listener 1883 127.0.0.1
allow_anonymous trueNote the 127.0.0.1. An anonymous listener bound to all interfaces is an open relay; binding to loopback keeps this step local-only. Run it in the foreground so you can watch it:
mosquitto -c /etc/mosquitto/mosquitto.conf -vConfirm it works, in two other terminals:
mosquitto_sub -h 127.0.0.1 -p 1883 -t 'test/#' -v
mosquitto_pub -h 127.0.0.1 -p 1883 -t 'test/hello' -m 'it works'That is your baseline. Everything from here replaces allow_anonymous true with something defensible.
Step 2: a private CA
You need a CA because you are going to issue certificates that only your broker trusts. Public CAs will not (and should not) issue you a certificate for device-001.
Initialise it:
step ca init \
--deployment-type standalone \
--name "IoT Fleet CA" \
--dns ca.internal.example.com \
--address ":9000" \
--provisioner admin@example.com \
--password-file ./ca-password.txt \
--provisioner-password-file ./provisioner-password.txtThis creates a root and an intermediate under $(step path). That two-level split is not ceremony: the root's key signs the intermediate once and can then live offline, while the intermediate does day-to-day issuance. If the intermediate is ever compromised you replace it without reissuing the trust anchor baked into every device.
The files that matter:
| Path | What it is | Who gets it |
|---|---|---|
certs/root_ca.crt | Trust anchor | Broker and every device |
certs/intermediate_ca.crt | Signs leaves | Broker (see below) |
secrets/root_ca_key | Root private key | Nobody. Keep offline. |
secrets/intermediate_ca_key | Issuing key | The CA host only |
Start the CA:
step-ca $(step path)/config/ca.json --password-file ./ca-password.txtStep 3: the broker's own certificate
The broker needs a server certificate whose SANs cover every name clients will use to reach it. Clients verify the hostname against the SAN list, not the CN, and you cannot add a SAN later without reissuing — so list them all now:
step ca certificate broker.example.com \
/etc/mosquitto/certs/server.crt \
/etc/mosquitto/certs/server.key \
--provisioner admin@example.com \
--san broker.example.com \
--san 10.0.1.5 \
--san localhost \
--not-after 8760hA missing SAN is the single most common cause of "it works with -h localhost but not with the real hostname".
Step 4: the trust store
This is the file Mosquitto uses to decide whether a client certificate is legitimate. Make it the full chain — intermediate first, then root:
cat $(step path)/certs/intermediate_ca.crt \
$(step path)/certs/root_ca.crt \
| sudo tee /etc/mosquitto/certs/ca-chain.crtA root-only file often appears to work here, because clients usually send the intermediate along with their leaf during the handshake. Include the intermediate anyway. It costs nothing, and it removes a whole class of chain-building failure — including one that becomes unavoidable later, when you enable revocation checking.
Fix up ownership, or Mosquitto will fail to start with a permissions error that does not mention permissions:
sudo chown mosquitto:mosquitto /etc/mosquitto/certs/server.key
sudo chmod 600 /etc/mosquitto/certs/server.key
sudo chmod 644 /etc/mosquitto/certs/ca-chain.crt /etc/mosquitto/certs/server.crtStep 5: TLS, then mutual TLS
Replace the plaintext listener. Two directives turn ordinary TLS into mutual TLS:
listener 8883
protocol mqtt
allow_anonymous false
cafile /etc/mosquitto/certs/ca-chain.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
require_certificate true
use_identity_as_username true
tls_version tlsv1.2require_certificate true is the one that matters. Without it you have server-side TLS only — traffic is encrypted, and anyone at all can connect. Encryption is not authentication, and this is the line people most often leave out while believing they have secured the broker.
use_identity_as_username true is what makes the rest of this tutorial worthwhile: Mosquitto takes the CN from the verified client certificate and uses it as the MQTT username for the session. The device does not send a username, and cannot claim one it does not hold a key for.
Step 6: issue a device certificate
One per device, with the device identifier as the subject:
step ca certificate device-001 device-001.crt device-001.key \
--provisioner admin@example.com \
--not-after 2160h2160h is 90 days, and short lifetimes are a deliberate choice rather than an inconvenience. A certificate that expires on its own bounds your exposure if a device is lost or a key leaks: stop renewing it and access lapses without any revocation machinery at all. We issue considerably shorter than that in production — 24 hours by default — precisely because it makes the whole system forgiving.
Each device gets device-001.crt, device-001.key, and a copy of root_ca.crt so it can verify the broker in return. The private key is generated on, and never leaves, the device in a real provisioning flow; generating it centrally as above is fine for a bench setup, not for a fleet.
Step 7: test the handshake
mosquitto_sub -h broker.example.com -p 8883 \
--cafile root_ca.crt \
--cert device-001.crt \
--key device-001.key \
-t 'devices/device-001/#' -vIf that subscribes, the full loop just worked: your client verified the broker's certificate against the root, the broker verified your client's certificate against the chain, and the session is now authenticated as device-001.
Prove the negative too — an unauthenticated client must be refused:
mosquitto_sub -h broker.example.com -p 8883 --cafile root_ca.crt -t '#'
# Expected: the broker rejects the connection.If that succeeds, require_certificate true is not in effect and you have an open broker behind a TLS listener.
Step 8: authorise per device
Authentication tells you who is connecting. Authorisation decides what they may touch — and without it, every device with a valid certificate can read every other device's data.
Because the CN is now the username, one pattern rule covers the whole fleet. %u expands to the connected username:
# /etc/mosquitto/acl.conf
pattern readwrite devices/%u/#acl_file /etc/mosquitto/acl.confdevice-001 can now publish and subscribe under devices/device-001/# and nowhere else. No per-device configuration, no database — the certificate is the identity and the identity is the boundary. Confirm the restriction actually holds:
# Allowed
mosquitto_pub -h broker.example.com -p 8883 \
--cafile root_ca.crt --cert device-001.crt --key device-001.key \
-t 'devices/device-001/telemetry' -m '{"temp_c":21.4}'
# Denied — not this device's subtree
mosquitto_pub -h broker.example.com -p 8883 \
--cafile root_ca.crt --cert device-001.crt --key device-001.key \
-t 'devices/device-002/telemetry' -m 'should not arrive'A denied publish is quiet by design at QoS 0 — check the broker log rather than expecting a client-side error.
Step 9: separate devices from your own services
Once this works you will immediately want a second listener, because your backend services are not devices. They connect with long-lived certificates, they need wide topic access, and they should not be governed by the device ACL:
# Devices: short-lived certs, narrow ACL
listener 8883
protocol mqtt
allow_anonymous false
require_certificate true
use_identity_as_username true
cafile /etc/mosquitto/certs/ca-chain.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
tls_version tlsv1.2
# Internal services: long-lived, directly issued certs
listener 8884
protocol mqtt
allow_anonymous false
require_certificate true
use_identity_as_username true
cafile /etc/mosquitto/certs/ca-chain.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
tls_version tlsv1.2Splitting the two is worth doing early. The policies genuinely diverge — certificate lifetime, ACL breadth, and, as you will see in a moment, whether revocation is enforced at all.
Step 10: renewal
Short-lived certificates only work if renewal is automatic. step ca renew authenticates using the existing certificate and key, so no provisioner secret needs to live on the device:
step ca renew device-001.crt device-001.key --forceAs a background daemon that reloads the consumer on success:
step ca renew device-001.crt device-001.key \
--daemon --exec "systemctl reload my-device-agent"Renewal fails closed. If the CA is unreachable past the renewal window, the certificate simply expires and the device drops off until it can reach the CA again. That is the correct behaviour — a device that cannot prove its identity should not keep transmitting on the strength of an old one.
Troubleshooting
Error: Unable to load CA certificateson startup. Almost always file permissions. Mosquitto drops to themosquittouser and cannot read a key that isroot:root 600. Check withsudo -u mosquitto cat /etc/mosquitto/certs/server.key.- Client fails with hostname verification errors. The name you passed to
-his not in the server certificate's SAN list. Inspect it withopenssl x509 -in server.crt -noout -text | grep -A1 "Subject Alternative Name". SANs cannot be added after issuance — reissue. - Everything connects, including clients with no certificate.
require_certificate trueis missing, or is sitting in the wrong listener block. Directives apply to the listener they follow, so a stray blank line or an ordering mistake can silently attach them to the wrong one. use_identity_as_usernameseems to do nothing. It only populates the username from a verified client certificate, so it is inert withoutrequire_certificate true.- ACL changes are ignored. Mosquitto reads the ACL file at startup and on
SIGHUP; runsystemctl reload mosquitto(not restart) to apply them without dropping clients. - Certificate is valid but rejected anyway. Check the clock. TLS validity windows are absolute timestamps, and a device whose RTC never synced will reject a perfectly good certificate as not-yet-valid. Sync time before the first TLS connection, not after.
What this still does not do
You now have per-device cryptographic identity, per-device authorisation, encrypted transport, and automatic renewal. There is exactly one thing missing, and it is the one you will need on your worst day: you cannot take a certificate away before it expires.
If a device is stolen tomorrow, its certificate stays valid for the rest of its lifetime. Short TTLs bound that window — which is why they matter so much — but bounding is not revoking. Closing the gap means a Certificate Revocation List, and enabling one is where this otherwise-clean setup falls apart in a genuinely surprising way: adding a single crlfile line to the config above will cause every device to be rejected, including ones you never revoked.
That failure, why it happens, and the configuration that actually works are covered in the follow-up: Mosquitto CRL Revocation: Why crlfile Rejects Every Client.
Wrap-up
The shape worth carrying away is that the certificate is not just a transport credential — with use_identity_as_username, it is the identity your authorisation rules are written against. That single line is what lets a whole fleet be governed by one pattern readwrite devices/%u/# rule instead of a credential store you have to keep in sync. Get the SANs right, put the intermediate in the trust store, split device and service listeners early, and keep certificate lifetimes short; the rest is the same broker you started with.