A door that only opens once: nonces, TTLs, and acks over MQTT
The trust boundary is the interesting part
Connecting a web backend to a physical door lock over MQTT sounds simple until you think about what MQTT actually guarantees: a publish/subscribe broker, not a secure request/response channel. Anyone who can see traffic on that broker can, in principle, replay a message. For most IoT use cases that's a minor annoyance. For a door lock, a replayed "unlock" message is a real security bug, not just noise. gym-iot's protocol is built specifically to close that gap, and most of the design is there for exactly that reason — not for the "make the door open" happy path, which is the easy 10% of the problem.
The unlock payload
A member's unlock request from the app becomes this JSON message published to gym/door/cmd:
interface UnlockPayload {
action: 'unlock';
doorId: string;
nonce: string;
ttl: number;
ts: number;
}
nonce is a fresh UUID generated per request — never reused, never derived from anything predictable. ttl and ts together let the receiving device decide whether a message is stale before it does anything physical.
What the backend checks before it ever touches MQTT
The unlock route doesn't just forward a request to the broker. Before publishing anything, it checks the member exists, is active, and hasn't had their plan expire — logging a denied AccessLog entry with a reason if any of that fails, so there's an audit trail even for rejected attempts, not just successful ones. It then checks Redis for the freshly-generated nonce before using it:
const existing = await redis.get(`unlock:nonce:${nonce}`);
if (existing) {
return NextResponse.json({ success: false, error: 'Duplicate request detected' }, { status: 409 });
}
await redis.setex(`unlock:nonce:${nonce}`, 30, '1');
Since the nonce is freshly generated per call, this specific check mostly guards against the backend racing itself (a double-submitted request from the app, a retry firing before the first attempt returned) rather than a genuine third-party replay — but it's the same defense-in-depth instinct that shows up again, more critically, on the firmware side.
The firmware's own replay defense
The ESP32 doesn't trust that the backend's nonce check is the only line of defense — it keeps its own record of the last 10 nonces it's already processed and rejects a repeat outright:
if (isDuplicateNonce(nonce)) {
displayLog("[MQTT] Dup nonce!");
return; // already processed, ignore silently
}
That's the part that actually matters against a genuine replay: even if someone captures a legitimate unlock message and re-publishes it later, the firmware has already marked that specific nonce as spent and won't act on it twice. On top of that, if the firmware's clock has synced over NTP, it separately checks the message's age against its ttl:
long commandAge = (long)now - (long)tsSec;
if (commandAge > ttl || commandAge < 0) {
sendAck(nonce, "failed", "Expired", "TTL exceeded");
return;
}
A message older than its TTL — 30 seconds, in the backend's payload — gets rejected even if the nonce were somehow never seen before. Two independent checks, on two different properties, running on the device that actually controls the relay — not just trusted from whatever validated the request upstream.
Acknowledgment is a three-step handshake, not fire-and-forget
The backend doesn't consider an unlock request done the moment it publishes to MQTT. It subscribes to gym/door/ack and tracks each pending request by nonce, waiting through two separate timeouts:
pending.timeoutReceived = setTimeout(() => {
console.error('[MQTT] ESP32 did not acknowledge receipt (timeout)');
}, 5000);
pending.timeoutFinal = setTimeout(() => {
pendingUnlocks.delete(nonce);
reject(new Error('ESP32 device not responding.'));
}, 15000);
The firmware sends a received ack the moment it accepts the command (clearing the 5-second timeout, proving the device is online and the message got through), then a separate success or failed ack once the relay has actually fired. The HTTP request to the mobile app doesn't resolve until that final ack arrives — the API caller genuinely knows whether the physical door opened, not just whether a message was published to a broker somewhere.
Where this still relies on trust
Nothing here authenticates that the MQTT message itself came from the real backend — MQTT_USER/MQTT_PASSWORD gate broker access, but a client with valid broker credentials could still publish a syntactically correct unlock command. The nonce and TTL checks stop replay of a previously captured message; they don't cryptographically verify the message's origin. For a single-tenant deployment on a broker the backend fully controls, broker-level auth is the actual trust boundary — the nonce/TTL layer is what protects against a captured-and-replayed message specifically, which is a different and narrower threat than an attacker with valid broker credentials.