esp32 intermediate 40 min

ESP32-CAM: QR code scanning for access control

Turn the ESP32-CAM into a QR code scanner: on-board decode with the quirc library, valid-code list, relay output for the door strike.

Code available for: ESP32 ArduinoArduino C
Published Sep 22, 2026

The ESP32-CAM can read QR codes straight off the frame buffer, which turns a $10 camera into an access-control reader: show your phone’s QR to the camera, the door unlocks. No cloud, no app service, the valid code list lives in your own flash.

This tutorial builds the scanner with the quirc QR library, a code allowlist, and the relay output for the strike.

What you need

  • ESP32-CAM (AI-Thinker)
  • The QR codes you will scan: any phone QR generator (e.g. an app or a free website) producing codes that contain your secret strings
  • A relay module if you are actually driving a door strike (the relay tutorial covers it; this tutorial outputs a GPIO)
  • FTDI adapter for programming

Enrolling your codes

The QRs you scan must match VALID_CODES in the flash. Two practical ways to generate them:

  • Phone app (e.g. any free QR generator) encoding the literal string door-bx7k2q. Screenshot it, save it to your phone’s favorites. Your phone is now the credential.
  • Printed cards for people without the app: same string, printed on paper, laminated. Paper credentials work exactly as well (e.g. this is how workshop walls get a “guest QR” that is trivially revocable: reprint the card AND change the flash string).

Change either side, re-upload the sketch. There is no server to sync because there is no server.

The honest engineering note

QR decoding on an ESP32 is real but modest: quirc decodes VGA frames in about 200-400 ms, and the camera needs the code fairly square to the lens, in focus range (the OV2640’s fixed focus is 40 cm to infinity), with even light. It is excellent for a garage door or a workshop entrance where people hold their phone up. It is not a sub-second turnstile reader. Knowing this going in saves an afternoon of tuning.

Compared to the RFID tutorial’s card reader: QR-on-phone is easier to issue and revoke (no hardware, regenerate in seconds), and RFID is faster and works in the rain with gloves on. They are alternatives, not competitors; build the one that matches who is knocking.

The code

#include "esp_camera.h"
#include <quirc.h>   // the QR decode library
#include <WiFi.h>

// Valid codes: flash strings (rotatable by regenerating QRs, e.g. monthly)
const char* VALID_CODES[] = {"door-bx7k2q", "guest-tz9m4w"};
const int NUM_CODES = 2;
const int STRIKE_PIN = 13;      // to relay module
const int LED_PIN = 33;         // onboard flash LED as a "granted" flash

struct quirc *q;

void setup() {
  Serial.begin(115200);
  pinMode(STRIKE_PIN, OUTPUT);

  // camera init identical to the streaming tutorial (VGA, grayscale ok)
  // camera_config_t ...
  config.frame_size = FRAMESIZE_VGA;
  config.pixel_format = PIXFORMAT_RGB565;   // quirc wants pixel buffers

  q = quirc_new();
  quirc_resize(q, 640, 480);

  // ... esp_camera_init, wifi, etc ...
}

void loop() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) return;

  uint8_t *buf = quirc_begin(q, NULL, NULL);
  // downsample RGB565 -> quirc's grayscale buffer
  for (int i = 0; i < 640*480; i++) {
    uint16_t px = ((uint16_t*)fb->buf)[i];
    buf[i] = ((px >> 8) & 0xF8) | ((px >> 5) & 0x07);   // luminance approx
  }
  quirc_end(q);

  int n = quirc_count(q);
  for (int i = 0; i < n; i++) {
    struct quirc_code code;
    struct quirc_data data;
    quirc_decode_error_t err = quirc_decode(&q_codes[i], &data);
    if (!err && is_valid((const char*)data.payload)) {
      grantAccess();
    }
  }
  esp_camera_fb_return(fb);
}

bool is_valid(const char* code) {
  for (int i = 0; i < NUM_CODES; i++) {
    if (strcmp(code, VALID_CODES[i]) == 0) return true;
  }
  return false;
}

void grantAccess() {
  digitalWrite(STRIKE_PIN, HIGH);
  delay(3000);              // strike released 3 s
  digitalWrite(STRIKE_PIN, LOW);
  Serial.println("access granted");
}

(Variable declarations for the loop’s quirc_code array are elided; the quirc example sketch in the library has the exact loop body. The structure above is the full pattern.)

The access-control hygiene

The code list lives in flash, which is the right place for a garage door (an attacker with physical access to the board wins regardless of your auth scheme). The rules that matter:

  • Codes are capabilities, not identities. Anyone holding the QR opens the door. Regenerate the code (and the flash string) to revoke (e.g. after a guest leaves).
  • Log every grant and denial. Print timestamped events to serial or push them with the ntfy tutorial, so a door opening at 3 AM is a notification, not a mystery.
  • Fail closed. If the camera dies, the strike stays locked; the loop only opens on a decoded valid code.

Lighting and placement

The QR read rate lives or dies on lighting:

ConditionResult
Even indoor light, 30-50 cmReliable, near-100%
Bright backlight (door in sun, holder in shade)Unreliable
Night, no fill lightDead without the LED

The onboard LED (GPIO 33) doubles as fill light for night use, though at 40 cm it mostly makes glare. A small external LED panel aimed at the scan zone works far better. Mount the camera so the QR arrives flat-ish (e.g. a printed card taped beside the door at hand height that says “hold your QR here”).

What you learned

  • quirc decodes QR from camera frames on-device; no server round trip.
  • Valid-code strings in flash + a GPIO to a relay is the whole access path.
  • Read rate is a lighting problem before it is a code problem.

When something breaks

  • Never decodes: pixel format. quirc wants the luminance buffer; the RGB565 downsampling line above is load-bearing. Also try JPEG->decode libraries if you stayed in PIXFORMAT_JPEG.
  • Decodes only sometimes: camera angle (keep code square to the lens) and resolution (VGA beats QVGA for dense QR codes).
  • Grants on garbage strings: you skipped the strcmp list and granted on any successful decode. is_valid() is the whole point.
  • Relay chatters: the strike draws more than the module passes. Flyback diode across the strike coil, and a real supply for the strike (not the ESP32’s 5V rail).

What to build next

  • The RFID tutorial is the card-based alternative to phone QRs.
  • The streaming tutorial adds a live view to debug the scan zone.
  • The ntfy tutorial logs every door event to your phone.