DoorCTL is a warehouse loading-dock door control system built for a real production need at a commercial laundry facility in Brooklyn, NY. It replaces reliance on physical wall buttons with a web interface where every action is authenticated, authorized by role, and fully logged.
The project was deliberately built to a production-grade standard — not as a prototype, but as a system real employees use every day. That means that unlike a typical "ESP32 + relay + button" build, it adds JWT authentication, WebAuthn biometrics, role-based access control (RBAC), and Row-Level Security at the database layer.
ArchitectureThe system is organized into four layers:
- PWA (Progressive Web App) — Vanilla JavaScript, hosted on Netlify. The operator signs in with a password or biometrics, sees live door status, and can review the activity history.
- Supabase — Postgres database plus Edge Functions (Deno). This is where all authentication and authorization happens: JWT issuing and verification, WebAuthn verification, and a role check on every request.
- Blynk Cloud — used purely as a pass-through command bridge (Virtual Pins V1/V2/V3). No user identification happens at the Blynk layer; that responsibility sits entirely with the PWA and Supabase.
- ESP32 firmware — receives commands from Blynk, drives a 2-channel relay (Active HIGH, GPIO26 = OPEN, GPIO27 = CLOSE), and reads both a 433 MHz RF remote (QIACHIP RX480E) and a physical STOP button.
Components:
- ESP32 DevKit — main controller
- 2-channel relay module (SRD-05VDC-SL-C, Active HIGH) — simulates a button press on the door controller
- QIACHIP RX480E — 433 MHz RF receiver for the physical remote
- Physical STOP button — dry contact on GPIO25 + GND
Key design decision — Active HIGH: GPIO26/27 default to LOW at ESP32 boot, so the relays stay off during startup. This rules out the door being triggered accidentally when the ESP32 reboots.
// Active HIGH: LOW is the boot default, so relays stay off at power-up
#define RELAY_OPEN 26
#define RELAY_CLOSE 27
#define RELAY_ON HIGH
#define RELAY_OFF LOW
void setup() {
// First command — turn off relays
pinMode(RELAY_OPEN, OUTPUT); digitalWrite(RELAY_OPEN, RELAY_OFF);
pinMode(RELAY_CLOSE, OUTPUT); digitalWrite(RELAY_CLOSE, RELAY_OFF);
pinMode(STOP_PHYSICAL, INPUT_PULLUP);
pinMode(REMOTE_OPEN, INPUT_PULLUP);
}Galvanic isolation: the relay contacts (COM/NO) are electrically isolated from the ESP32's logic circuit — the only link between the coil and the contacts is magnetic. This matters: the door controller's circuit never reaches the ESP32's 3.3 V GPIO pins.
This is the part of the project that goes furthest beyond the hardware.
JWT (HMAC-SHA256): a token is generated at login using the Web Crypto API (no external library), stored in localStorage, and attached to every admin request via an X-Doorctl-Token header.
// Sign a payload as an HS256 JWT using Web Crypto (no external deps)
async function signJWT(payload: Record<string, unknown>): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(JWT_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
return `${data}.${b64url(new Uint8Array(sig))}`;
}WebAuthn biometrics: real challenge-response authentication using @simplewebauthn/server. The private key never leaves the device's secure enclave — the server only verifies the signature.
RBAC — three tiers (admin / operator / read_only): the core principle is that the role is never baked into the JWT.
On every admin request, the role is read live from the database. If an administrator demotes someone, the change takes effect immediately — no waiting for a 30-day token to expire.
// Verifies the token, then re-checks the caller's role directly from the
// database on every call — so a role downgrade takes effect immediately,
// without waiting for the token to expire.
async function requireAdmin(req: Request, supabase) {
const token = req.headers.get('x-doorctl-token');
if (!token) return { error: json({ error: 'Missing token' }, 401) };
const payload = await verifyJWT(token); // signature + expiry only
if (!payload) return { error: json({ error: 'Invalid or expired token' }, 401) };
const { data: userRow } = await supabase
.from('users').select('role').eq('username', payload.username).single();
if (!userRow || userRow.role !== 'admin') {
return { error: json({ error: 'Forbidden' }, 403) };
}
return { username: payload.username };
}Audit logging: every door action — whether it comes from the app, the RF remote, or the physical button — is written to the log with the user who triggered it, the source, and a timestamp.
Row-Level Security: enabled at the Postgres level on the logs and users tables — an additional barrier that holds independently of the Edge Function logic.
Blynk cannot identify users. An early attempt tried to identify the user inside the firmware's V-pin handlers. That turned out to be the wrong call: Blynk is only a pass-through command channel, and all user attribution has to happen at the PWA/Supabase layer.
loop() blocking is a real trade-off. Supabase HTTP calls (300–800 ms) block the ESP32's single core, which in theory means a 433 MHz or STOP pulse could be missed if it lands within a second or two of another action. Relay control is never at risk — only logging is. Async or dual-core FreeRTOS would solve this, but at a significant complexity cost, so for now it is a deliberate trade-off.
// Logs are queued, not sent inline. The HTTP call runs only after every
// button has been polled, so a STOP press is never missed while waiting
// on the network.
void queueLog(String username, String action) {
pendingLog = true;
pendingLogUser = username;
pendingLogAction = action;
}
void loop() {
// ... relay timers, physical STOP, remote OPEN/CLOSE/STOP ...
if (pendingLog) {
pendingLog = false;
logToSupabase(pendingLogUser, pendingLogAction);
}Parallelism requires FreeRTOS. A separate void function outside loop() does not create parallelism — it is still called from the loop and blocks in exactly the same way.
Hardware isolation is not optional. Wiring ESP32 pins directly in parallel with an existing control circuit is dangerous even when a shared ground seems obvious — optocouplers or relay dry contacts are required.
What's next- Client-side role check on door button press (an additional UX layer)
- Bypass detection: the firmware writes every Blynk signal to a separate table; a scheduled function reconciles it against the Activity Log within a ±30-second window
- Multi-door support
- GitHub (public, portfolio-clean):
github.com/nlatsabidze-hw/doorctl-system - Live demo:
doorctl-demo.netlify.app















Comments