This is a development version of the documentation. Content may change without notice.
Voke Documentation
PLC / Devices

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

  1. You generate an RSA key pair (generate-mqtt-jwt-keys.sh or your own tooling).
  2. The public key (PKCS#1 DER format) is enrolled in Voke — the broker loads it from plugin_opt_jwt_sec_file.
  3. The PLC signs a JWT with the private key and passes it as the MQTT password.
  4. The broker's JWT plugin (wiomoc/mosquitto-jwt-auth v0.4.0, RS256) validates the signature and the exp / sub claims.
  5. plugin_opt_jwt_validate_sub_match_username true is set — the MQTT username field must match the sub claim 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.sh

It produces four files in docker/mosquitto/certs/jwt/:

FileFormatUsed by
jwt-private.pemPEM PKCS#1 private keyAPI / PLC to sign JWTs
jwt-public.pemPEM SPKI public keyHuman reference only
jwt-public.derDER PKCS#1 public keyMosquitto broker (plugin_opt_jwt_sec_file)
jwt-public-spki.derDER SPKI public keyNot 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.der

The broker is configured to load this file:

plugin_opt_jwt_sec_file /mosquitto/certs/jwt/jwt-public.der

Required JWT claims

The broker validates:

ClaimRequirementNotes
subPlant UUIDMust match the MQTT username field (enforced by plugin_opt_jwt_validate_sub_match_username true)
expFuture Unix timestampValidated by plugin_opt_jwt_validate_exp true; broker rejects expired tokens
iatPresent Unix timestampIncluded 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:

  1. Generate a new pair: ./generate-mqtt-jwt-keys.sh (confirms overwrite).
  2. Restart the broker so it reloads jwt-public.der:
    docker compose restart mosquitto
  3. 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

SymptomLikely cause
CONNACK rc=5 — connected with wrong keyPublic key in SPKI DER instead of PKCS#1 DER
CONNACK rc=5 — token expiredexp is in the past; issue a fresh token
CONNACK rc=5 — username mismatchMQTT usernamesub claim in the JWT
Port 8884 unreachable (local)Running on Apple Silicon; JWT listener is disabled
CONNACK rc=5 — wrong algorithmToken signed with HS256 or ES256; only RS256 is accepted

Next steps

On this page