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 | Messages are acknowledged with PUBACK. |
| 2 (exactly once) | Downgraded to QoS 1 | LavinMQ does not implement the full QoS 2 handshake. |
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
- The session queue is durable
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. 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
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
- MQTT topic separators (
/) map directly to AMQP routing key segments - Message properties are mapped between protocols (e.g.,
delivery_modemaps to QoS,mqtt.retainheader tracks retain flag)
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 |
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 - 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. Deleting thedefaultgroup is not reliably carried over by a definitions export and import, see Definitions - 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 \
-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. If this file does not exist, an import with groups for that vhost replaces the automatic default group. If the file exists, an import adds groups and replaces groups by name, and deletes none. Closing a vhost saves its current groups, including the default group if it is still present. 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 only saved permission groups. If mqtt_permissions.json is missing, the generator includes no groups for that vhost.
Deleting the default group is not reliably carried over by an export and import:
- If the vhost has no other groups, the export has no entries for it, so the import leaves the target as it is
- Otherwise, a target vhost that already has
mqtt_permissions.jsonkeeps itsdefaultgroup. Every vhost has that file after its first restart or change to its groups
To make a lockdown survive, keep the default group and remove or narrow its rule instead. load_definitions never replaces existing groups, so there this only takes effect on a vhost without mqtt_permissions.json, such as one the definitions file creates.
Upgrading
- The
defaultgroup is created in memory when a vhost has nomqtt_permissions.json, so an upgraded server keeps every topic open until an operator locks a vhost down.mqtt_permissions.jsonis written at the first change over the HTTP API or from a definitions import, or when the vhost closes - 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.
- AMQP and MQTT components cannot be cross-connected. Exchange-to-exchange bindings between the MQTT exchange and AMQP exchanges are not supported, so an AMQP publisher cannot reach MQTT subscribers (or vice versa) within the same broker.
- 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.