MQTT with LavinMQ
LavinMQ implements MQTT 3.1.0 and 3.1.1 natively. MQTT clients connect directly to the dedicated MQTT port and use the protocol as-is; no plugin or external proxy is required. Internally, LavinMQ maps MQTT concepts onto its AMQP infrastructure (sessions become queues, subscriptions become bindings), but this is invisible to MQTT clients.
Ports
| Protocol | Default Port | Config Key |
|---|---|---|
| MQTT | 1883 | mqtt_port |
| MQTTS | 8883 | mqtts_port |
| MQTT over WebSocket | via HTTP port (15672) | http_port |
Unix domain sockets are also supported via unix_path in the [mqtt] section. See Configuration below.
QoS Levels
| QoS | Supported | Behavior |
|---|---|---|
| 0 (at most once) | Yes | Fire and forget. Messages are not persisted for the session. |
| 1 (at least once) | Yes | Incoming publishes are acknowledged with PUBACK after the affected durable data is synced, unless synchronization is disabled. |
| 2 (exactly once) | Downgraded to QoS 1 | LavinMQ does not implement the full QoS 2 handshake. |
For incoming QoS 1 publishes, PUBACKs are sent in publish order after the broker finishes handling the publish and syncing its affected durable message and retained-message files. In a cluster, the broker also waits for the in-sync followers to acknowledge the replicated writes after synchronization. This uses the same persistence mechanism as publisher confirms.
Waiting for disk synchronization makes QoS 1 throughput depend on disk latency and the number of publishes the client keeps in flight. QoS 0 does not wait for this synchronization. Setting sync=false in [main] (or using --no-sync) skips local disk synchronization; followers also skip synchronization if it is disabled in their own configuration. A PUBACK then provides no disk durability guarantee on those nodes.
A PUBACK acknowledges the broker’s handling of a publish, not delivery to a subscriber. Session lifetime and subscriptions still determine whether messages are retained for later delivery; a publish denied by topic permissions is acknowledged and dropped as described below.
Sessions
Each MQTT session is implemented as an internal AMQP queue named mqtt.<client_id>. The queue holds the session’s pending QoS 1 messages and tracks subscriptions as bindings. This is an implementation detail of how LavinMQ stores session state — MQTT clients never see the queue directly, but it explains why session names share the mqtt. prefix and why durability and lifetime follow the AMQP queue model.
Clean Sessions
When a client connects with clean_session=true:
- Any existing session for the client ID is deleted
- A new transient (auto-delete) session is created
- Subscriptions and unacknowledged messages are discarded on disconnect
Persistent Sessions
When a client connects with clean_session=false:
- The session persists across disconnections
- Subscriptions are preserved
- Unacknowledged QoS 1 messages are requeued and redelivered on reconnect, under the packet IDs the client already holds and with the
dupflag set - The session queue is durable
The reuse of packet IDs on redelivery is remembered in-process only, so after a broker restart the session’s outstanding messages are redelivered under fresh packet IDs. If a max-length policy or a purge discards a message the session still owes, its packet ID is forgotten along with it; the messages that remain keep theirs.
Session Takeover
If a client connects with a client ID that already has an active connection, the existing connection is closed and the new client takes over the session.
Session Limits
Sessions count towards the vhost’s max-queues limit, together with AMQP queues. A session is created on the client’s first SUBSCRIBE, so CONNECT still succeeds when the vhost is at the limit, but the SUBSCRIBE is answered with a SUBACK where every topic filter gets return code 0x80 (failure). Clients that already have a session can keep subscribing, since reusing a session consumes no new resource.
Message Delivery
- QoS 0 messages are not enqueued if no consumer (client) is currently connected to the session
- QoS 1 messages are stored in the session queue and tracked with packet IDs
- Unacknowledged messages are requeued when a persistent session client disconnects or a new client takes over, and keep their packet IDs for the redelivery. For clean sessions, unacknowledged messages are discarded.
Connection Limits
The max-connections vhost limit applies to MQTT connections as well as AMQP ones. When the vhost is at its cap, a CONNECT is answered with a CONNACK carrying return code 3 (server unavailable) and the socket is closed. A client reconnecting with a client ID that already has an active connection is still accepted, because session takeover replaces that connection instead of adding one. See Connections.
Retained Messages
Retained messages are stored per topic and delivered to new subscribers upon subscription.
- When a message is published with the retain flag set, it is stored in the retain store
- When a client subscribes to a topic, any matching retained message is delivered immediately
- Publishing a retained message with an empty payload clears the retained message for that topic
- Retained messages are replicated across cluster nodes
Topic Matching
MQTT topics use / as a level separator. LavinMQ supports the standard MQTT wildcards:
+— matches exactly one topic level#— matches zero or more topic levels (must be the last character)
Examples:
- sensor/+/temperature matches sensor/room1/temperature but not sensor/room1/sub/temperature
- sensor/# matches sensor/room1/temperature and sensor/room1/sub/anything
- sensor/# also matches sensor, because # includes the parent level
A filter that starts with # or + does not match a topic that starts with $. To get messages on $ topics, subscribe to a filter that starts with the $ level, for example $SYS/#.
MQTT-AMQP Bridge
Internally, MQTT is implemented on top of LavinMQ’s AMQP infrastructure:
- A dedicated MQTT exchange handles topic routing
- Each MQTT session is an AMQP queue
- MQTT subscriptions are bindings on the MQTT exchange
- The MQTT topic is used unchanged as the routing key:
/is not translated to., also not for messages delivered to AMQP consumers through anx-mqtt-topicexchange - Message properties are mapped between protocols (e.g.,
delivery_modemaps to QoS,mqtt.retainheader tracks retain flag)
Consuming MQTT Messages over AMQP
An exchange of type x-mqtt-topic makes MQTT publishes available to AMQP consumers. Declare one, then bind queues to it with MQTT topic filters as binding keys:
exchange.declare name=sensors type=x-mqtt-topic durable=true
queue.declare name=sensor-readings durable=true
queue.bind queue=sensor-readings exchange=sensors routing_key=sensors/+/temp
Every MQTT publish whose topic matches sensors/+/temp is delivered to sensor-readings. The AMQP message has the exchange’s name as exchange, the MQTT topic as routing key with its / separators intact, the MQTT payload as body and delivery_mode 2. The MQTT publisher needs no change; a QoS 1 PUBACK is sent once the message is persisted in the AMQP queue as well.
- The exchange is always internal, so AMQP clients cannot publish into it
- Binding keys use MQTT filter syntax and follow the same rules as a SUBSCRIBE:
#only as the last level,+only as a whole level. A malformed filter is refused withPRECONDITION_FAILEDover AMQP and400over the HTTP API - The AMQP user needs read permission on the exchange and write permission on the queue to bind. Topic permissions are not applied to AMQP destinations
- Retained messages are not replayed when a binding is created
- Exchanges can be bound too, see MQTT Topic Exchange
Configuration
| Config Key | Section | Default | Description |
|---|---|---|---|
bind |
[mqtt] |
127.0.0.1 |
Bind address for MQTT |
port |
[mqtt] |
1883 |
MQTT listen port |
tls_port |
[mqtt] |
8883 |
MQTT over TLS port |
unix_path |
[mqtt] |
(empty) | Unix socket path |
max_inflight_messages |
[mqtt] |
65535 |
Max unacknowledged messages per session, must be at least 1 |
max_packet_size |
[mqtt] |
268435455 |
Max MQTT packet size in bytes |
default_vhost |
[mqtt] |
/ |
Default vhost for MQTT connections |
client_id_validation |
[mqtt] |
none |
Validate client_id against the username: none or username |
Topic Permissions
Topic permissions restrict which topics a user’s MQTT clients can publish to and receive from. They are defined as permission groups on a vhost.
- A client can only publish to or receive on topics granted by a matching rule
- Every vhost starts with a group named
default. Its member is*and its single rule#grants read and write, so any authenticated client can publish and subscribe to any topic. A vhost created by a definitions import is the exception, see Definitions - To lock a vhost down, delete the
defaultgroup or narrow its rule. Groups added next to an intactdefaultgroup grant nothing new, because thedefaultgroup already grants everything - A vhost with no groups denies every topic
- There is no administrator bypass
- A user still needs a permission entry on the vhost to connect
- The
permission_check_enabledoption under[mqtt]adds the AMQP permission check in front of the topic check, see Upgrading
Groups
A permission group has a name, a list of members and a list of rules.
{
"name": "devices",
"vhost": "/",
"members": ["alice"],
"rules": [
{ "identifier": "own-chat", "pattern": "chat/{client_id}/#", "read": true, "write": true }
]
}
- Group names consist of alphanumerics, hyphens and underscores, at most 255 characters
- Members are usernames. Every connection that authenticates as a member gets the group’s rules, so a user with many devices is one member
- The member
"*"applies the group to every authenticated user - A user in several groups gets the rules of all of them
- A member name must match the user name as shown in the connections list. For OAuth users that is the claim selected by
preferred_username_claims - Each rule has an identifier, a topic filter pattern and
readandwriteflags - Rule identifiers consist of alphanumerics and hyphens and are unique within the group. The HTTP API addresses a rule by its identifier
Patterns
Patterns are MQTT topic filters. They use the + and # wildcards with subscription semantics, so a rule for a/# also grants a.
A pattern can contain {client_id} as a whole topic level. It is replaced with the client ID of the connection being checked, so one rule gives each of a user’s devices its own subtree:
chat/{client_id}/#grantschat/thermo-1/#to a device connected asthermo-1- The same rule grants
chat/gate/#to a device connected asgate - A client ID that contains
/,+or#never matches a topic level, so rules with{client_id}never match for that connection
The client ID has no other role. Membership is decided by the authenticated username, which the client cannot choose.
Enforcement
- Publish: the connection needs a write rule for the topic. A denied publish is dropped, a QoS 1 publish is still acknowledged, and the connection stays open
- Subscribe: always accepted. Read is enforced when a message is accepted into the session, so a subscription to a filter the user cannot read receives no messages. This matches Mosquitto
- Will: the connection needs a write rule for the will topic, otherwise the will is dropped
- Denials are logged at debug level
- Changes to groups take effect immediately, also for connected clients
Read is checked once per message, when the message is accepted into the session, not when it is delivered to the client. A message accepted before read was revoked is still delivered after the revocation. Messages published after the revocation are not.
Sessions
A session is checked with the username of the client that last attached to it. This also covers messages that arrive while the device is offline.
- The session stores that username on disk, so a session restored after a restart keeps its member rules until the device reconnects
- When another user takes over the session (see Session Takeover), new messages are checked against the new user
- Messages already queued under the previous user are still delivered
HTTP API
| Method | Path | Description |
|---|---|---|
| GET | /api/mqtt/permission-groups |
List group summaries on all vhosts |
| GET | /api/mqtt/permission-groups/{vhost} |
List group summaries on a vhost |
| GET | /api/mqtt/permission-groups/{vhost}/{name} |
Get one group summary |
| PUT | /api/mqtt/permission-groups/{vhost}/{name} |
Create an empty group (no request body) |
| DELETE | /api/mqtt/permission-groups/{vhost}/{name} |
Delete a group with all its members and rules |
| GET | /api/mqtt/permission-groups/{vhost}/{name}/members |
List the members of a group |
| PUT | /api/mqtt/permission-groups/{vhost}/{name}/members/{username} |
Add a member |
| DELETE | /api/mqtt/permission-groups/{vhost}/{name}/members/{username} |
Remove a member |
| GET | /api/mqtt/permission-groups/{vhost}/{name}/rules |
List the rules of a group |
| PUT | /api/mqtt/permission-groups/{vhost}/{name}/rules/{identifier} |
Add or replace a rule; body {"pattern": "...", "read": bool, "write": bool} |
| DELETE | /api/mqtt/permission-groups/{vhost}/{name}/rules/{identifier} |
Remove a rule |
- All routes require the administrator tag
- A group summary has
name,vhost,member_countandrule_count - The members route returns one object per member:
{"username": "..."} - The group list routes and the members route accept
page,page_size,namewith optionaluse_regex=true,sort,sort_reverseandcolumns, like the other list endpoints - The rules route returns the full rule list with
identifier,pattern,readandwriteper rule
Example: allow every user to use only its own device subtrees under chat/.
curl -u admin:pw -X PUT localhost:15672/api/mqtt/permission-groups/%2f/devices
curl -u admin:pw -X PUT localhost:15672/api/mqtt/permission-groups/%2f/devices/members/%2A
curl -u admin:pw -X PUT localhost:15672/api/mqtt/permission-groups/%2f/devices/rules/own-chat \
-H 'Content-Type: application/json' \
-d '{"pattern": "chat/{client_id}/#", "read": true, "write": true}'
Permission changes are saved to disk before becoming active. If saving fails, the request fails and the previous permissions remain active, including when attempting to revoke access.
Definitions
Groups are stored per vhost in mqtt_permissions.json and are included in definitions export and import under the mqtt_permissions key. The file is written when the vhost is created, so it always holds the groups that are in effect, including the default group.
An import adds groups and replaces groups with the same name. It never deletes a group, like for users and policies, so importing definitions into an existing vhost keeps its default group. To lock that vhost down, delete or narrow its default group. load_definitions also skips groups whose name already exists on the vhost.
A vhost created by an import only gets the default group if the definitions have no mqtt_permissions key. With the key, which every export has, the vhost gets only the groups the definitions list for it, so a vhost exported without groups is imported locked down. Definitions from before topic permissions have no such key, and their vhosts stay open.
Definitions imports save and apply permission groups together per vhost; a failure on one vhost does not undo changes already saved for another.
Definitions generated from a data directory include the groups in mqtt_permissions.json. If the file is missing, the generator includes the default group, which the server creates when it loads the vhost.
Upgrading
- A vhost without
mqtt_permissions.jsongets thedefaultgroup, which is written to that file at once, so an upgraded server keeps every topic open until an operator locks a vhost down - The
permission_check_enabledoption under[mqtt]is unchanged. When it is set, a publish needs write permission on themqtt.defaultexchange, and a subscribe needs read permission on that exchange and write permission on themqtt.<client_id>session queue. A client that fails this check is disconnected. The topic check runs after it - A persistent session that existed before the upgrade has no stored username until its device reconnects once. Until then it is checked against
"*"rules only
Authentication
MQTT clients authenticate using the CONNECT packet’s username and password fields. These are validated against the same authentication chain as AMQP (local users, OAuth2). For OAuth2, the password field carries the JWT token.
The username field can include a vhost using the format vhost:username. If no colon is present, default_vhost is used.
Client ID Validation
By default any client_id is accepted. Since the client_id is chosen freely by the client, it cannot be trusted for identity purposes on its own. The client_id_validation setting ties it to the authenticated username:
username: the client_id must be equal to the username
A CONNECT with a non-conforming client_id is rejected with return code 2 (identifier rejected) and the connection is closed. An empty client_id is automatically assigned a conforming one. When the username includes a vhost (vhost:username), the client_id is validated against the username part only.
Note that connecting with a client_id already in use takes over that session, so username mode limits each user to one connection at a time.
Limitations
- Only MQTT 3.1.0 and 3.1.1 are supported. MQTT 5 features (session expiry interval, shared subscriptions, topic aliases, message expiry, user properties, response topics) are not available.
- QoS 2 is downgraded to QoS 1 — the full four-step QoS 2 handshake (PUBREC/PUBREL/PUBCOMP) is not implemented.
- Federation and shovels operate at the AMQP layer. There is no MQTT-level bridging between brokers.
- An AMQP publisher cannot reach MQTT subscribers within the same broker. The other direction is supported through the
x-mqtt-topicexchange. - MQTT topics are mapped to AMQP routing keys, so AMQP routing key constraints apply (length and encoding).
Ready to take the next steps?
Managed LavinMQ instance via CloudAMQP
LavinMQ has been built with performance and ease of use in mind - we've benchmarked a throughput of about 1,000,000 messages/sec. You can try LavinMQ without any installation hassle by creating a free instance on CloudAMQP. Signing up is a breeze.
Get started with CloudAMQP ->Help and feedback
We welcome your feedback and are eager to address any questions you may have about this piece or using LavinMQ. Join our Slack channel to connect with us directly. You can also find LavinMQ on GitHub.