Authentication & access
Who may connect, and which topics each client may publish, subscribe to and receive.
Mercurio offers several ways to authenticate clients and decide their access. They combine as follows:
| Need | Use |
|---|---|
| A fixed list of users | password file and ACL file |
| Users and access managed while the broker runs | dynamic security |
| Users kept in your own database or identity service | HTTP authentication |
| Clients holding tokens from an identity provider | JSON Web Tokens, with an ACL file or HTTP topic checks |
| Devices identified by certificates | client certificates, with an ACL file |
| Custom logic in Rust | the server library's traits |
Password files
[auth] enabled = true allow_anonymous = false password_file = "/etc/mercurio/passwd"
With enabled = true, clients must send a user name and password found in password_file. With allow_anonymous = true, clients that send no user name are let in as anonymous; credentials that are sent are always checked.
The file holds one user:hash line per user. Manage it with mercuriod passwd, which writes Argon2 hashes:
# Add a user, or change their password (prompts for it) mercuriod passwd -f /etc/mercurio/passwd -u alice # Remove a user mercuriod passwd -f /etc/mercurio/passwd -u alice -d
mercuriod passwd replaces the file in one step, so a reload never sees half of it. The file keeps its owner, group and mode, at most 0640; a new file gets 0600, so let the broker read it with chgrp mercurio and chmod 640. A file it cannot read in full, such as one with a malformed line, is left unchanged with an error.
Files written by mosquitto_passwd work unchanged: their $7$ (PBKDF2-SHA512, mosquitto 2.0's default) and $6$ (SHA-512, older versions) hashes are recognised. Any other hash field, such as a plain-text password or an empty one, stops the file from loading, with an error naming its line.
Next to the Argon2 hash, mercuriod passwd stores the user's SCRAM-SHA-256 keys, on a line of their own after the user's:
alice:$argon2id$v=19$m=19456,t=2,p=1$... #scram-sha-256:alice:<digest of the hash>:SCRAM-SHA-256$600000:<salt>$<StoredKey>:<ServerKey>
The line starts with #, so mosquitto_passwd and earlier mercuriod releases read it as a comment, and the file stays readable by both. The digest ties the keys to the hash they were set with: when another tool, such as mosquitto_passwd, changes the password, the keys no longer match and are not used, so the old password does not keep working over SCRAM. A malformed #scram-sha-256: line stops the file from loading, as a malformed hash does.
Access control lists
[auth] acl_file = "/etc/mercurio/acl"
The file follows mosquitto's acl_file format, so existing files can be reused:
# Rules before any `user` line apply to anonymous clients topic read public/# user alice topic readwrite alice/# topic deny alice/secret # Patterns apply to every client; %u is the user name, %c the client id pattern write devices/%c/status pattern read users/%u/inbox
- The access is
read,write,readwrite(the default) ordeny.readcovers subscribing and receiving;writecovers publishing. - Anything not granted is denied, and
denyoverrides any grant. - A subscription is allowed only when a rule covers its whole filter: a client granted
sensors/+/temperaturemay subscribe to it, but not tosensors/#. - Shared subscriptions are checked by their topic filter:
$share/group/sensors/+/temperatureneeds the same grant assensors/+/temperature. userrules only apply to user names verified by authentication. Other clients are matched as anonymous.- A will counts as a publish: a client whose will topic it may not publish to is refused at CONNECT.
An ACL file works without passwords too, for example with client certificates.
$SYS topics only accept the broker's own statistics. $CONTROL topics, which administer the broker, are refused to every client unless an ACL file, dynamic security or an HTTP ACL endpoint grants them. Administration lists the grants its commands need; clients of the admin socket need none.TLS and client certificates
[server.tls] enabled = true cert_path = "/etc/mercurio/server.crt" key_path = "/etc/mercurio/server.key" # Require client certificates signed by this CA ca_path = "/etc/mercurio/ca.crt" # Refuse revoked ones crl_path = "/etc/mercurio/crl.pem" # The certificate names the client identity_as_username = true # Refuse TLS 1.2 min_version = "1.3"
- With
ca_path, clients must present a certificate signed by that CA, or the handshake fails. require_certificate = falsestill asks for a certificate but lets clients without one connect, to log in by user name and password like on a listener withoutca_path. A client that presents an invalid certificate is still refused.identity_as_usernameandsubject_as_usernamethen apply only to clients that presented one.crl_pathrefuses certificates revoked in the given CRLs. Each certificate of the client's chain, its intermediate CAs included, must be covered by a CRL from its issuer: a client issued by an intermediate CA is refused unless the file holds the CRLs of both the root and the intermediate. Two settings relax this, for a PKI where some CA publishes no CRL:crl_end_entity_only = truechecks only the client's own certificate, so the CRL of the CA that issued it is enough.crl_allow_unknown = trueaccepts certificates whose issuer has no CRL in the file, and refuses only those a CRL revokes.
identity_as_usernamemakes the certificate's Common Name the client's user name. The client counts as authenticated, the user name and password in CONNECT are ignored, anduserrules in the ACL file apply to it.subject_as_usernameuses the whole subject instead, in RFC 2253 form as mosquitto and OpenSSL print it, such asCN=alice,O=Acme\, Inc,C=PT.
A device fleet authenticated by certificates alone:
[server] host = "0.0.0.0" port = 8883 [server.tls] enabled = true cert_path = "/etc/mercurio/server.crt" key_path = "/etc/mercurio/server.key" ca_path = "/etc/mercurio/devices-ca.crt" identity_as_username = true [auth] enabled = true allow_anonymous = false acl_file = "/etc/mercurio/acl"
# /etc/mercurio/acl: each device writes its own telemetry
pattern write devices/%u/telemetry
pattern read devices/%u/commands
Each listener has its own TLS settings; see Listeners.
Dynamic security
Dynamic security manages clients, roles and access while the broker runs, with the JSON commands of mosquitto's dynamic security plugin. It replaces password_file and acl_file.
# Create the state file with an administrator (prompts for the password)
sudo mercuriod dynsec-init -f /var/lib/mercurio/dynamic-security.json -u admin
The file is created with mode 0600. Run as root in a directory owned by another user, as the service's state directory /var/lib/mercurio is, dynsec-init gives the file to that directory's owner and group, so the service can read it. systemd creates that directory when the service first starts: start it once before, with systemctl start mercuriod, if it has never run.
Coming from mosquitto, create it from mosquitto's file instead:
sudo mercuriod dynsec-import /var/lib/mosquitto/dynamic-security.json /var/lib/mercurio/dynamic-security.json
dynsec-import carries over clients, groups, roles, ACLs, priorities, the default access and the anonymous group, and gives the file to the directory's owner as dynsec-init does. It never overwrites an existing file. Clients keep their passwords: mosquitto's PBKDF2-SHA512 hashes are checked as they are, and a password set again with setClientPassword or modifyClient is stored as Argon2. A PBKDF2 check is faster than an Argon2 one, so until then the time a login takes can tell an imported client from an unknown user name; set those passwords again to close that. What can't be carried over is listed and left out: unsubscribeLiteral and unsubscribePattern ACLs and a default that denies unsubscribing (Mercurio lets every client unsubscribe), passwords in another form (those clients can't log in until they get one), and names a client, group or role refers to that the file does not define.
Setting a password with dynsec-init, createClient, setClientPassword or modifyClient also stores the client's SCRAM-SHA-256 keys, in the client's scram field, tied to its hash as in the password file. Earlier releases ignore the field. getClient never shows it. A client that is disabled, or bound to another client id, cannot log in with SCRAM either.
[auth] enabled = true allow_anonymous = false dynamic_security = "/var/lib/mercurio/dynamic-security.json"
Manage it with mercurioctl, on the broker's host through the admin socket, or from anywhere as the administrator:
# On the broker's host, as root or a member of the mercurio group mercurioctl dynsec create-role sensor mercurioctl dynsec add-role-acl sensor publishClientSend 'sensors/#' allow mercurioctl dynsec create-group sensors --role sensor mercurioctl dynsec create-client s1 --group sensors # asks for the password # From another machine mercurioctl -H broker.example.com -u admin dynsec list-clients
Underneath, these are JSON commands published to $CONTROL/dynamic-security/v1, with the names and arguments of mosquitto's plugin. They use the same envelope and replies as the broker's own commands. Failed commands carry an error, without a code.
Only clients of the admin socket and of listeners that use the main [auth] can send commands: a listener with its own [listeners.auth] doesn't include dynamic security.
Model
- Clients log in with a user name and password, and can be disabled. A client created with a
clientidmay connect only with that client id, not with one the broker assigns. A user name dynamic security has no client for, such as one taken from a certificate withidentity_as_username, gets no access at all: create a client of that name, without a password, to give it roles. A client deleted while connected loses its access at once. - Roles hold ACLs. A client has its own roles and those of its groups.
- Groups give their roles to their members. The anonymous group's roles apply to clients without a user name.
- ACLs have a type, a topic filter, an optional
priority(default 0) andallow. - Priorities order a client's roles and groups, a group's roles and a role's ACLs, highest first; a role or group given without one has -1, and equal priorities keep the order they were added in. A check walks the client's own roles first, then each of its groups' roles, group by group, and within each role its ACLs: the first ACL that matches decides, as in mosquitto. A role's ACL priority therefore orders ACLs inside that role only: a deny in a client's own role wins over an allow of higher priority in one of its groups.
| ACL type | Decides |
|---|---|
publishClientSend | Publishing |
publishClientReceive | Receiving messages |
subscribePattern | Subscribing to filters covered by the topic |
subscribeLiteral | Subscribing to exactly the topic |
Without a matching ACL, only receiving is allowed, as in mosquitto. setDefaultACLAccess changes that, with publishClientSend, publishClientReceive and subscribe.
Commands
| Area | Commands |
|---|---|
| Clients | createClient, modifyClient, deleteClient, setClientPassword, enableClient, disableClient, addClientRole, removeClientRole, getClient, listClients |
| Roles | createRole, modifyRole, deleteRole, addRoleACL, removeRoleACL, getRole, listRoles |
| Groups | createGroup, modifyGroup, deleteGroup, addGroupRole, removeGroupRole, addGroupClient, removeGroupClient, getGroup, listGroups, setAnonymousGroup, getAnonymousGroup |
| Defaults | setDefaultACLAccess, getDefaultACLAccess |
modifyClient takes password, clientid, textname, textdescription, roles and groups; modifyGroup takes textname, textdescription, roles and clients; modifyRole takes textname, textdescription and acls. As in mosquitto, an argument left out leaves that part as it is, and a list replaces the whole list: "roles": [] takes every role away. An empty clientid lets the client connect with any client id, and an empty password leaves the password as it is. Every argument is checked before anything changes, so a command that fails changes nothing.
Changes take effect at once and are saved to the file after each message. As in mosquitto, a change that can take access away disconnects the connected clients it concerns, with reason code Administrative Action (0x98) and without their wills, so they log in again under their new access: deleting or disabling a client or setting its password, adding or removing a client's roles or groups, adding or removing a group's roles, deleting a group or a role, adding or removing a role's ACLs, and the modify commands, unless they change only text. A modifyGroup that changes its clients disconnects those it loses and those it gains. Changes to the anonymous group disconnect anonymous clients that came over the network; in-process clients and those of Unix sockets stay connected. The file is replaced in one step, so a crash never leaves it half written.
HTTP authentication
Delegates checks to an HTTP service, with the requests of mosquitto-go-auth's HTTP backend, so services written for it answer Mercurio too.
[auth] enabled = true allow_anonymous = false [auth.http] user_url = "https://auth.internal/mqtt/user" acl_url = "https://auth.internal/mqtt/acl" timeout_seconds = 5 cache_seconds = 30
| Endpoint | Request body |
|---|---|
user_url, at each login | {"username": "alice", "password": "...", "clientid": ""} |
acl_url, at each topic check | {"username": "alice", "clientid": "c1", "topic": "a/b", "acc": 2} |
superuser_url, before a topic check | {"username": "alice"} |
acc is 1 to receive, 2 to publish and 4 to subscribe, and 8 to publish to a $CONTROL/... topic, which administers the broker; answer 8 with 2xx only for administrators. A 2xx status allows and a 4xx status denies. Topic answers are reused for cache_seconds, since every message is checked. Any other status, an error or a timeout also denies, but is not cached, so the check is asked again once the service is back; the broker logs these failures at warn, at most once every 10 seconds. At most 32 requests run at once, and identical checks made while one is out wait for its answer. A user_url that is plain http:// to another host is logged at startup, as passwords then cross the network unencrypted. user_url replaces password_file, and acl_url replaces acl_file; either can be used alone.
With superuser_url, the broker first asks whether the client's user name is a superuser's. A 2xx answer makes it one: every topic check is allowed without asking acl_url, publishing to $CONTROL included, so a superuser may administer the broker. A 4xx answer, or any failure of the endpoint, means not a superuser, and acl_url decides as usual; without acl_url, the next check, such as acl_file, does. Superuser answers are cached for cache_seconds like topic answers, failures are not. Clients without a user name are never asked about. superuser_url needs enabled = true, as a user name nobody checked would otherwise make anyone a superuser.
Over https://, endpoint certificates are checked against the bundled Mozilla roots. For a service with a certificate of a private CA, give that CA's certificates with ca_path, which then replaces the bundled roots; for a service that authenticates the broker with a client certificate, give it with cert_path and its key with key_path. All three are PEM files, read at startup and by mercuriod --check; a file that can't be read or holds no certificate or key stops the broker from starting. A reload reads them again for later requests, and keeps the previous ones if any fails to load.
[auth.http] acl_url = "https://auth.internal/mqtt/acl" ca_path = "/etc/mercurio/auth-ca.pem" cert_path = "/etc/mercurio/auth-client.pem" key_path = "/etc/mercurio/auth-client-key.pem"
A minimal service, in Python:
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class Auth(BaseHTTPRequestHandler):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if self.path == "/mqtt/user":
ok = body["username"] == "alice" and body["password"] == "secret"
else:
ok = body["topic"].startswith(f"{body['username']}/")
self.send_response(200 if ok else 403)
self.end_headers()
HTTPServer(("127.0.0.1", 8000), Auth).serve_forever()
JSON Web Tokens
Clients can log in with a JSON Web Token (JWT) issued by your identity provider. The client sends the token as its CONNECT password, and the CONNECT user name must equal the token's user claim, sub unless username_claim names another. Access control is keyed by user name, so ACL files, acl_url and the rest work for these clients as for any other.
[auth] enabled = true acl_file = "/etc/mercurio/acl" [auth.jwt] public_key_file = "/etc/mercurio/jwt.pem" algorithms = ["RS256"] issuer = "https://issuer.example" audience = "mercurio"
A password that does not look like a JWT, three base64url parts separated by dots whose first part is a JSON object with an alg, is left to the next check, so a password_file or user_url can keep serving other clients. With nothing else configured, such a client is refused as for a wrong password. A password that looks like a JWT and fails any check is refused with Bad User Name or Password (return code 4 in MQTT 3.1.1), counts as a failed login, and is logged at warn level with the client id, its address and the reason, never the token.
The broker checks:
- the signature, with an algorithm from
algorithms. The token's ownalgheader never picks the key or its type, and unsigned tokens (alg: none) are always refused; exp, which the token must have, andnbfwhen present, each withleeway_secondsof clock skew allowed;issandaud, whenissuerandaudienceare set: the token must then carry them;- the user claim, which must be a string equal to the CONNECT user name.
Keys come from exactly one of:
secret_file, a shared secret of at least 32 bytes for HS256, HS384 and HS512. One trailing newline is removed, so a file written byechoworks.public_key_file, a PEM public key for RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384 or EdDSA.jwks_url, the issuer's JSON Web Key Set, fetched at startup; the broker does not start without it. A token naming akidthe set lacks makes the broker fetch it again, at most once everyjwks_refresh_seconds, and a failed fetch keeps the previous keys. The URL must behttps://with a certificate from a publicly trusted CA, orhttp://to a loopback address for testing; there is noca_pathfor it, and redirects are not followed. HMAC algorithms can't be used with it, as their keys are secret.
All algorithms listed must take the same kind of key. A reload rereads secret_file and public_key_file, so keys can be rotated without a restart.
By default a client is disconnected once its token expires, with Not Authorized (MQTT 3.x clients are just disconnected); without that, a token valid for five minutes would buy a connection that lasts for ever. The broker checks expiries every [server] plugin_tick_interval, one second by default, so a client may stay up to that long after exp plus leeway_seconds, or up to 30 seconds after logging in when the broker is too busy to report the connection to the check. A client renews its token by reconnecting with a new one, which replaces the old expiry; re-authenticating a live connection with a token is not supported. The client's will is published, as for a lost connection. disconnect_on_expiry = false lets clients stay until they leave.
Tokens carry no access rules: give topic access with an ACL file or acl_url. JWT authentication needs enabled = true, and can't be combined with dynamic security, which owns its users.
To try it, make a token for alice, valid for five minutes, signed with the secret in /etc/mercurio/jwt.secret, and connect with it:
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
header=$(printf '{"alg":"HS256","typ":"JWT"}' | b64)
payload=$(printf '{"sub":"alice","exp":%d}' $(($(date +%s) + 300)) | b64)
signature=$(printf '%s.%s' "$header" "$payload" \
| openssl dgst -sha256 -hmac "$(cat /etc/mercurio/jwt.secret)" -binary | b64)
RECADO_PASSWORD="$header.$payload.$signature" recado sub -u alice -t '#'
MQTT 5 enhanced authentication
MQTT 5 clients can authenticate with a method through the AUTH packet, and re-authenticate during a connection. The daemon offers SCRAM-SHA-256 with a password file or dynamic security. Applications embedding the broker register other methods through the server library. See Embedding.
SCRAM-SHA-256
With SCRAM-SHA-256 (RFC 5802 and RFC 7677, method name SCRAM-SHA-256), the client proves it knows the password without sending it, and the broker proves it knows the user's keys: its signature goes in the CONNACK, or in the AUTH that ends a re-authentication, and the Mercurio client refuses a broker without it. Nothing needs configuring: with password_file or dynamic_security set, the broker offers the method, and every user whose password was set by this release or later can use it. Plain user name and password logins keep working for the same users.
- User names are compared after SASLprep (RFC 4013), so
userwith a soft hyphen anduserare the same user for SCRAM. The keys of a user are found by the prepared name, and ACLs and roles are matched against the name the client is known by after SCRAM, which is the prepared one. So new users must be created with names already in SASLprep form:mercuriod passwdand the dynamic security commands that set a password refuse any other name, and one that cannot be prepared at all. Names in older password files that SASLprep changes log in by SCRAM under the prepared spelling. - Users whose password was set before, or by
mosquitto_passwd, or imported from mosquitto, have no SCRAM keys: their plain logins work, and SCRAM fails for them as a wrong password does, until the password is set again. - New keys use 600,000 PBKDF2-HMAC-SHA256 iterations. The broker computes them once, when the password is set; a login costs it a few hashes whatever the count. The client derives them on its first login, about 0.15 s on a desktop CPU, and the Mercurio client keeps the result for reconnections. Small devices that cannot afford that should log in with a user name and password over TLS.
- Channel binding: see SCRAM-SHA-256-PLUS. A client that asks for a method the broker does not offer is refused with Bad Authentication Method. A wrong password or an unknown user gives Bad User Name or Password, and counts as a failed login like any other. The refusal carries a short reason string standing in for the RFC's server error, such as
wrong user name or password, unless the client sent Request Problem Information 0; a wrong password and an unknown user get the same one. - For an unknown user, the broker answers with a salt derived from the user name and a secret, and the usual iteration count, so its first message does not show whether the user exists. Unless
scram_secret_filekeeps the secret, it is new on every start, so comparing answers across restarts can still tell.
SCRAM-SHA-256-PLUS
SCRAM-SHA-256-PLUS ties the login to the TLS connection it runs on, so a machine that relays the exchange between a client and the broker cannot reuse it. The broker offers it only on TLS 1.3 connections that it terminates itself, and binds with tls-exporter (RFC 9266) only; tls-unique and tls-server-end-point are not supported. TLS 1.2, plain TCP, Unix sockets and connections behind a TLS-terminating proxy have no exporter, so there the method is not available and clients use SCRAM-SHA-256. A client that uses SCRAM-SHA-256-PLUS with a binding that does not match the connection is refused with Malformed Packet and the reason channel bindings don't match.
Set scram_require_channel_binding = true in [auth] to refuse SCRAM-SHA-256 without binding on TLS 1.3 connections, so clients there must use SCRAM-SHA-256-PLUS. It does not restrict the other connections: plain TCP, Unix sockets, TLS 1.2 and connections behind a TLS-terminating proxy keep using SCRAM-SHA-256, and it does not affect plain user name and password logins. The refusal is Bad Authentication Method with the reason channel binding is required.
Connect with SCRAM from the command line with --auth-method scram-sha-256, see recado, or --auth-method scram-sha-256-plus for SCRAM-SHA-256-PLUS, or from Rust with ScramSha256, see the client library.
Client id ownership
Each mount point has its own client ids: the same id on listeners with different mount points names two independent sessions, with their own subscriptions, queues, in-flight messages and wills, and neither takes the other over, closes it or ends it. Listeners without a mount point share one set of ids, with in-process clients such as bridges and plugin connections.
Within a mount point, a client id belongs to the owner of its session for as long as that session exists, connected or not. The owner is the user the client authenticated as, or anonymous. A client of the same owner takes over the id as usual: the connection that held it closes with Session Taken Over. A client of another owner is refused with Not Authorized (return code 5 in MQTT 3.1.1), Clean Start or not, and the connection, session, queued messages and will of the owner stay as they were. The broker logs each refusal at warn level with the client id, its mount point and both owners. With use_username_as_clientid, a user has one session on each mount point it connects to.
A session left by a previous owner blocks the id until it expires. To give the id to someone else sooner, delete the session with mercurioctl client delete-session <clientid>, adding --mount-point when the id is on more than one. A ban on a client id holds on every mount point.
Reloading
Send SIGHUP, or run systemctl reload mercuriod, to reread every password file, ACL file and dynamic security file, the TLS certificate, key, CA and CRL files, those of listeners included, the CA and client certificate files of [auth.http], and the key files of [auth.jwt], without dropping connections. mercurioctl broker reload does the same and reports how each file went.
- New ACL rules apply to every later publish, subscribe and delivery, connected clients included.
- Password and JWT key changes apply to later logins.
- TLS changes apply to later connections, so a renewed certificate takes over without a restart. Connected clients keep the session they started with. A certificate whose key does not match, or a file that is missing, keeps the previous certificate, key, CA and CRLs of that listener.
- If a file fails to load, the broker logs the error and keeps the previous version, so a typo never locks everyone out.
- Other settings need a restart.
Plugin sandbox
WebAssembly plugins run with no files, no environment and no network, except the HTTP hosts and socket addresses listed in allow_http and allow_sockets. Their standard output and error are discarded; they log through the broker, and a Rust plugin's panic messages are logged at error level. Each call has a time limit and each copy a memory limit. A plugin that fails or runs out of time denies, for authentication and access control, and drops, for messages. See WebAssembly plugins.
Per-listener authentication
A listener can have its own [listeners.auth], replacing [auth] for its clients: for example, open access on a localhost listener and passwords on the public one. See Listeners.