JWT onboarding
Authenticate your PLC with RS256-signed JWTs on the Voke JWT listener.
The JWT listener authenticates devices via an RS256-signed JWT passed as the MQTT password field. The broker validates the signature against a public key you pre-enroll in Voke. No client certificate is required — only server-side TLS.
Listener ports: 8887 (production) · 8884 (local dev)
How it works
- You generate an RSA key pair (
generate-mqtt-jwt-keys.shor your own tooling). - The public key (PKCS#1 DER format) is enrolled in Voke — the broker loads it from
plugin_opt_jwt_sec_file. - The PLC signs a JWT with the private key and passes it as the MQTT password.
- The broker's JWT plugin (wiomoc/mosquitto-jwt-auth v0.4.0, RS256) validates the
signature and the
exp/subclaims. plugin_opt_jwt_validate_sub_match_username trueis set — the MQTT username field must match thesubclaim exactly.
Key format — CRITICAL
The broker plugin expects the RSA public key in PKCS#1 DER format, NOT SPKI DER.
The plugin passes raw bytes to the ring::UnparsedPublicKey API which expects PKCS#1
for RSA. SPKI DER silently fails with InvalidSignature on every connection — the
device connects with rc=5 and no useful error appears in the broker log.
The generate-mqtt-jwt-keys.sh script handles this automatically:
cd scripts/pki
./generate-mqtt-jwt-keys.shIt produces four files in docker/mosquitto/certs/jwt/:
| File | Format | Used by |
|---|---|---|
jwt-private.pem | PEM PKCS#1 private key | API / PLC to sign JWTs |
jwt-public.pem | PEM SPKI public key | Human reference only |
jwt-public.der | DER PKCS#1 public key | Mosquitto broker (plugin_opt_jwt_sec_file) |
jwt-public-spki.der | DER SPKI public key | Not used by broker — do not mount this |
If you already have a private key in PEM format and need to produce the correct DER file manually:
# Extract SPKI PEM first (if you only have the raw private key):
openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem
# Convert to PKCS#1 DER (what the broker needs):
openssl rsa -pubin -in jwt-public.pem -RSAPublicKey_out -outform DER -out jwt-public.derThe broker is configured to load this file:
plugin_opt_jwt_sec_file /mosquitto/certs/jwt/jwt-public.derRequired JWT claims
The broker validates:
| Claim | Requirement | Notes |
|---|---|---|
sub | Plant UUID | Must match the MQTT username field (enforced by plugin_opt_jwt_validate_sub_match_username true) |
exp | Future Unix timestamp | Validated by plugin_opt_jwt_validate_exp true; broker rejects expired tokens |
iat | Present Unix timestamp | Included by convention; not enforced by the plugin itself |
Voke's own token issuer (POST /api/v1/plants/{plantId}/mqtt-token) additionally
includes publ (allowed publish topics) and subs (allowed subscribe topics) as
non-standard claims for audit purposes, but the broker's wiomoc plugin only enforces
sub, exp, and the username match. The publ/subs claims are informational for
the API side; ACL enforcement still comes from Mosquitto's ACL file.
Algorithm: RS256 (configured via plugin_opt_jwt_alg RS256).
Recommended token lifetime: 1 hour. Maximum accepted by the Voke token endpoint: 24 hours. For long-running PLCs, implement on-device token refresh before expiry.
Option A: let Voke issue the token
For PLCs that do not hold the private key themselves, Voke's REST API can issue tokens
on demand. The plant must have authMethod = TOKEN.
# Issue a new MQTT JWT for a TOKEN-auth plant (valid 1 hour by default)
curl -X POST https://api.voke.lovinka.com/api/v1/plants/{plantId}/mqtt-token \
-H "Cookie: <your session cookie>"Response:
{
"token": "eyJ...",
"expiresAt": "2026-04-18T15:00:00.000Z"
}Pass this token as the MQTT password. Refresh it before expiresAt.
Option B: sign tokens on-device
If the PLC holds the private key it can sign JWTs itself without calling the API. Example using PyJWT:
import time
import jwt # pip install PyJWT
PRIVATE_KEY_PATH = "/etc/voke/jwt-private.pem"
PLANT_ID = "REPLACE-WITH-YOUR-PLANT-ID"
TOKEN_LIFETIME = 3600 # seconds
def issue_mqtt_token() -> str:
with open(PRIVATE_KEY_PATH) as f:
private_key = f.read()
now = int(time.time())
payload = {
"sub": PLANT_ID,
"iat": now,
"exp": now + TOKEN_LIFETIME,
}
return jwt.encode(payload, private_key, algorithm="RS256")The public key whose fingerprint matches this private key must be enrolled in Voke
(i.e. mounted as jwt-public.der in the broker container).
Connecting
The full Python example is in apps/docs/content/examples/plc/mqtt_jwt.py.
Key configuration:
import paho.mqtt.client as mqtt
HOST = "mqtt.voke.lovinka.com"
PORT = 8887 # production; 8884 for local dev
PLANT_ID = "REPLACE-WITH-YOUR-PLANT-ID"
# JWT signed with the private key whose public counterpart is enrolled in Voke.
JWT_TOKEN = issue_mqtt_token() # or fetch from POST /plants/{id}/mqtt-token
client = mqtt.Client(client_id=PLANT_ID, protocol=mqtt.MQTTv5)
# username = plant ID (must match JWT sub claim)
# password = signed JWT
client.username_pw_set(PLANT_ID, JWT_TOKEN)
# Standard server-side TLS — no client certificate.
client.tls_set()
client.connect(HOST, PORT, keepalive=60)ACL scope
JWT devices share the same pattern-based ACL as mTLS devices:
pattern write cpi/%u/telemetry
pattern write cpi/%u/status
pattern write cpi/%u/ack
pattern read cpi/%u/commands%u expands to the MQTT username, which the broker enforces matches the sub claim.
A plant can only write to its own telemetry topics and read from its own commands topic.
Token rotation
Short-lived tokens (recommended): Issue a fresh token before the current one expires. The PLC reconnects with the new token; no broker restart is needed.
Key rotation: To rotate the RSA key pair:
- Generate a new pair:
./generate-mqtt-jwt-keys.sh(confirms overwrite). - Restart the broker so it reloads
jwt-public.der:docker compose restart mosquitto - All existing tokens signed with the old key stop working immediately after the broker restarts. Deploy new tokens to all PLCs before restarting, or tolerate a brief reconnect window.
There is no dual-key rollover in the current plugin. Plan accordingly — prefer very short token lifetimes so the impact of an unplanned key rotation is minimal.
Local dev note (ARM64)
On Apple Silicon (aarch64), the JWT plugin (mosquitto_jwt_auth.so) is amd64-only and
cannot be loaded. The docker/mosquitto/entrypoint.sh script detects the architecture
and strips the entire JWT listener block before starting Mosquitto. Port 8884 is simply
not available on ARM64.
Use PASSWORD (8885) or mTLS (8883) for local development on M-series Macs.
Troubleshooting
| Symptom | Likely cause |
|---|---|
CONNACK rc=5 — connected with wrong key | Public key in SPKI DER instead of PKCS#1 DER |
CONNACK rc=5 — token expired | exp is in the past; issue a fresh token |
CONNACK rc=5 — username mismatch | MQTT username ≠ sub claim in the JWT |
| Port 8884 unreachable (local) | Running on Apple Silicon; JWT listener is disabled |
CONNACK rc=5 — wrong algorithm | Token signed with HS256 or ES256; only RS256 is accepted |
Next steps
- Auth methods — compare all three listeners.
- Topics — full topic reference.
- HMAC signing — sign telemetry payloads.