Building a Standalone RFID Door Lock with the RC522 and an ESP32

Building a standalone RFID door lock with the RC522 and an ESP32 that learns and forgets cards using a single push button.
Aug 31, 2026 — 13 mins read — Projects

Building a Standalone RFID Door Lock with the RC522 and an ESP32

Every time I walk up to a door with a badge reader, I think about how little is actually going on inside. A card gets close, the reader wakes it up, reads a number off it, and decides if that number is on the approved list. That is really it. So I wanted to build the smallest possible version of that on my bench, using the RFID RC522 module and an ESP32, with no screen, no app, and no network connection. Just a reader, a button, a relay, and some cards.


This video is sponsored by Altum Develop. Get your free trial here.


What We Are Building

The idea is a small door lock controller that works completely on its own. The star of the project is the RC522 module, which is one of the cheapest and most common RFID readers you can buy. It reads cards and tags at 13.56 MHz and hands you back the unique ID stored on each one.

The ESP32 sits behind the reader and does the thinking. I am using a DFRobot FireBeetle board for this, but almost any ESP32 will work. When a card is presented, the ESP32 reads its ID and compares it against a list of IDs saved in memory. If it finds a match, it switches on a relay for ten seconds and then switches it back off. That relay is what you would wire to a magnetic lock or a latching lock, because those are just electromagnets. Power them and they release, cut the power and they hold.

There is one master tag that is written directly into the code, so the system always has at least one key that works no matter what else has been added or erased. On top of that, the board can learn up to ten more cards on its own using a single push button. That is the part I find neat, because it means you never have to plug the thing back into a computer to add a new key.

I have a small pile of cards and tags to test with. There are the standard plastic cards, some wooden ones that look genuinely fancy, tiny keyfob tags, and even a sticker tag that you could embed into just about anything and use as a key. They all work the same way as far as the reader is concerned.


How the Cards Actually Work

Before wiring anything, it helps to understand what is inside one of these cards, because there is no battery in them and that surprises people.

If you hold one of the small tags up and shine a light through it from behind, you can see a thin coil of wire spiralling around the edge of the tag and a tiny black chip sitting right in the middle. The plastic cards are harder to see through, but if you press your phone torch against one you can just make out the chip and part of the antenna running around the inside edge.

That coil is the whole trick. The reader is constantly putting out a radio field, and when the card gets close enough, that field induces a small current in the coil. That current is enough to power up the chip, and once the chip is awake it transmits its UID, the unique identification number burned into it, along with whatever other data is stored on it. No battery needed, and it only works while the card is sitting in the reader's field.

One thing worth knowing is that not all cards give you an ID of the same length. Some of my tags return a four byte UID, while most of the cards I have return a seven byte UID. That comes down to the different chips used inside them. The code needs to handle both, which is why I store the length of the ID alongside the ID itself rather than assuming everything is four bytes.


Wiring Everything Up

The RC522 is a friendly module to work with. It can talk over SPI, I2C or UART depending on how you configure it, so it fits into pretty much any project. For this build I used SPI, because that is the mode the common Arduino libraries expect out of the box.

Using SPI means four signal wires, plus two for power, plus one for the reset pin on the module. Power is simple. The 3.3 V pin on the module goes to 3.3 V on the ESP32, and ground goes to ground. Do not feed this module 5 V, it is a 3.3 V part.

For the rest, the RST pin on the reader goes to GPIO 3 on the FireBeetle. MISO goes to pin 21, MOSI goes to pin 22, and SCK goes to pin 23. The pin labelled SDA on the module is actually the chip select line, and that goes to pin 2. If you are using a different ESP32 board, these pin numbers will change, but the pin names on the module stay the same.

Then there are two more connections on the ESP32 side. The push button goes to pin 9, and the relay control input goes to pin 7. The button is the only user interface this project has, so it is doing double duty, which I will come back to in a moment.

Powering the Relay

Powering the relay turned into the one genuinely awkward part of this build, and it is specific to the board I chose. My relay module is a 5 V part, and the FireBeetle has no 5 V output pin at all. On most ESP32 boards you would just grab the VIN pin, which carries the incoming 5 V from USB, and you would be done.

I did consider soldering a wire directly to one of the pins on the board's regulator, since that 5 V rail does physically arrive there. In the end I used the battery pin instead, the one intended for charging a lithium cell. That pin sits at around 4.2 V, and as it turns out that is just enough to make this particular relay trigger reliably. It is not the most orthodox choice, but it works for a demo and it saved me from soldering onto a regulator pad. If your board has a proper 5 V pin, use that instead.


Teaching the Board New Cards

With only a single button available, I had to get a bit creative in the code to give it two separate jobs. The board measures how long the button is held down and treats a short hold and a long hold as two completely different commands.

Holding the button for about a second registers as a short press, and that drops the board into learning mode. The next card you present gets its UID read and saved, and the board tells you the card is registered before returning to normal operation. From that point on, tapping that card on the reader clicks the relay on for ten seconds, exactly like the master tag does.

Holding the same button for more than five seconds does the opposite. That wipes every stored card out of memory. After a wipe, the board reports that there are no custom cards saved, and any card you added before that point stops working. The master tag still works, because that one lives in the code rather than in storage.

Testing this is satisfying in a very simple way. Before adding a card, presenting it to the reader gets you access denied and nothing happens. Press and hold, scan the same card, wait for it to return to normal mode, and now the same card opens the lock. Hold the button for five seconds and it goes back to being just a piece of plastic.


Storing Cards in Flash

The cards need to survive a power cut, and I did not want to add external memory for something this small. The ESP32 already has a solution for exactly this, which is the Preferences library.

Preferences is built into the ESP32 core, and some Arduino boards have their own equivalent. It gives you a simple way to write variables into a small reserved area of the board's flash memory and read them back later. Values written this way stay put across resets and power cycles, which is precisely what it was designed for. In this project I use it to store the ten card slots, so once you have taught the reader a card it stays taught until you deliberately wipe it.


Walking Through the Sketch

At the top of the sketch there are the library includes, including the library that handles talking to the RC522, followed by the pin definitions I listed earlier. Right after that is the master UID, hardcoded so there is always a working key available.

In the setup function I start the serial port, bring up the SPI bus, and initialise the reader module. Then I set the pin modes for the button and the relay, making sure the relay starts in the off state so the lock is not sitting open at boot. Finally I print out how many saved cards are currently in storage along with a few lines of instructions, so anyone watching the serial monitor knows what the button does.

The loop function is short. It checks the state of the button, then checks if a card is present on the reader. If a card is there, it reads the UID and compares it against the master UID and against everything in storage. A match switches on the relay for the set period, and no match prints access denied and leaves the lock alone.

The helper functions underneath handle the rest. There is one that figures out how long the button has been held so it can decide between add mode and wipe mode, one that enters card learning mode, one that compares two UIDs byte by byte, and one that writes a new card into flash through Preferences.

Below is the full code I used in the example.

Code

#include <SPI.h>
#include <MFRC522.h>
#include <Preferences.h> 

#define SPI_SCK      23  
#define SPI_MOSI     22  
#define SPI_MISO     21  
#define SS_PIN       2   
#define RST_PIN      3   
#define RELAY_PIN    7   
#define BUTTON_PIN   9   

MFRC522 mfrc522(SS_PIN, RST_PIN); 
Preferences preferences;          

#define MAX_CARDS     10
#define MAX_UID_SIZE  10  

// Matrices to store varying sizes
byte authorizedCards[MAX_CARDS][MAX_UID_SIZE];
byte cardSizes[MAX_CARDS]; 
int totalSavedCards = 0;

// Default backup Master Card
byte masterUID[] = {0x04, 0xAA, 0x81, 0xA1, 0x67, 0x26, 0x81}; 
byte masterSize = 7;

void setup() {
  Serial.begin(115200);
  while (!Serial); 
  
  SPI.begin(SPI_SCK, SPI_MISO, SPI_MOSI, SS_PIN);
  mfrc522.PCD_Init();
  
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, HIGH); // Preserved: HIGH keeps your relay locked/off initially
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  
  loadCardsFromMemory();
  
  Serial.println("\n--- FireBeetle ESP32-C6 Universal Smart Lock Ready ---");
  Serial.print("Saved custom cards: ");
  Serial.println(totalSavedCards);
  Serial.println("Button D9 Controls:");
  Serial.println(" -> Hold 1 second: Enter Learning Mode");
  Serial.println(" -> Hold 5 seconds: WIPE ALL CARDS");
}

void loop() {
  // Check if the button is pressed (LOW)
  if (digitalRead(BUTTON_PIN) == LOW) {
    handleButtonPress();
  }

  if (!mfrc522.PICC_IsNewCardPresent() || !mfrc522.PICC_ReadCardSerial()) {
    return;
  }
  
  Serial.print("Scanned UID (Size: " + String(mfrc522.uid.size) + " bytes):");
  printUID(mfrc522.uid.uidByte, mfrc522.uid.size);

  if (checkAuthority(mfrc522.uid.uidByte, mfrc522.uid.size)) {
    Serial.println(" -> ACCESS GRANTED!");
    digitalWrite(RELAY_PIN, LOW); // Preserved: LOW unlocks the door
    delay(5000);                   
    digitalWrite(RELAY_PIN, HIGH); // Preserved: HIGH locks it back up
    Serial.println("Lock secured.");
  } else {
    Serial.println(" -> ACCESS DENIED!");
  }
  
  mfrc522.PICC_HaltA();
  mfrc522.PCD_StopCrypto1();
}

// Evaluates button duration to choose between Learning or Wiping
void handleButtonPress() {
  unsigned long pressStartTime = millis();
  bool wipeTriggered = false;

  Serial.print("\n[BUTTON] Detecting press hold time...");
  
  // Keep looping as long as the user continues physically holding the button
  while (digitalRead(BUTTON_PIN) == LOW) {
    unsigned long heldDuration = millis() - pressStartTime;

    // Visual countdown feedback in the console every 1 second
    if (heldDuration >= 5000 && !wipeTriggered) {
      Serial.println("\n[WIPE] !!! 5 SECONDS REACHED !!!");
      wipeAllCards();
      wipeTriggered = true; 
      break; 
    }
    
    delay(100); 
    Serial.print(".");
  }

  // If they let go before 5 seconds, but after a solid press, go to learning mode
  if (!wipeTriggered) {
    unsigned long totalDuration = millis() - pressStartTime;
    if (totalDuration > 200) { // Debounce threshold
      enterLearningMode();
    }
  }
}

void wipeAllCards() {
  Serial.println("[WIPE] Erasing internal flash storage database...");
  
  preferences.begin("lock-data", false);
  preferences.clear(); // Wipes out all keys stored under the "lock-data" namespace
  preferences.end();
  
  // Clear running RAM variables
  totalSavedCards = 0;
  memset(authorizedCards, 0, sizeof(authorizedCards));
  memset(cardSizes, 0, sizeof(cardSizes));
  
  Serial.println("[WIPE] System successfully wiped! Rebooting hardware...");
  delay(1500);
  ESP.restart(); // Software reboots the ESP32-C6 to clean slate state
}

void enterLearningMode() {
  Serial.println("\n[LEARN] Entering Multi-Format Learning Mode...");
  Serial.println("[LEARN] Please scan any 4-byte or 7-byte card now...");
  delay(1000); 
  
  unsigned long startTime = millis();
  bool cardLearned = false;
  
  while (millis() - startTime < 15000) {
    if (mfrc522.PICC_IsNewCardPresent() && mfrc522.PICC_ReadCardSerial()) {
      
      if (mfrc522.uid.size > MAX_UID_SIZE) {
        Serial.println("[ERROR] Card UID length is too large for storage.");
      } else if (totalSavedCards >= MAX_CARDS) {
        Serial.println("[ERROR] Storage full! Cannot add more cards.");
      } else {
        cardSizes[totalSavedCards] = mfrc522.uid.size;
        for (int i = 0; i < mfrc522.uid.size; i++) {
          authorizedCards[totalSavedCards][i] = mfrc522.uid.uidByte[i];
        }
        totalSavedCards++;
        
        saveCardsToMemory();
        
        Serial.print("[SUCCESS] New card registered. Size recorded: ");
        Serial.print(mfrc522.uid.size);
        Serial.println(" bytes.");
        cardLearned = true;
      }
      
      mfrc522.PICC_HaltA();
      mfrc522.PCD_StopCrypto1();
      break; 
    }
    delay(100); 
  }
  
  if (!cardLearned) {
    Serial.println("[TIMEOUT] No card detected.");
  }
  Serial.println("--- Returned to Normal Mode ---\n");
  delay(1000);
}

bool checkAuthority(byte *scanned, byte size) {
  if (size == masterSize) {
    bool matchMaster = true;
    for (int i = 0; i < size; i++) {
      if (scanned[i] != masterUID[i]) matchMaster = false;
    }
    if (matchMaster) return true;
  }

  for (int slot = 0; slot < totalSavedCards; slot++) {
    if (size == cardSizes[slot]) { 
      bool matchStored = true;
      for (int i = 0; i < size; i++) {
        if (scanned[i] != authorizedCards[slot][i]) matchStored = false;
      }
      if (matchStored) return true; 
    }
  }
  return false; 
}

void saveCardsToMemory() {
  preferences.begin("lock-data", false); 
  preferences.putInt("cardCount", totalSavedCards);
  
  for (int i = 0; i < totalSavedCards; i++) {
    String keyUID = "u" + String(i);
    String keyLen = "l" + String(i);
    
    preferences.putBytes(keyUID.c_str(), authorizedCards[i], cardSizes[i]);
    preferences.putUChar(keyLen.c_str(), cardSizes[i]); 
  }
  preferences.end();
}

void loadCardsFromMemory() {
  preferences.begin("lock-data", true); 
  totalSavedCards = preferences.getInt("cardCount", 0);
  
  if (totalSavedCards > MAX_CARDS) totalSavedCards = MAX_CARDS;
  
  for (int i = 0; i < totalSavedCards; i++) {
    String keyUID = "u" + String(i);
    String keyLen = "l" + String(i);
    
    cardSizes[i] = preferences.getUChar(keyLen.c_str(), 0);
    preferences.getBytes(keyUID.c_str(), authorizedCards[i], cardSizes[i]);
  }
  preferences.end();
}

void printUID(byte *uid, byte size) {
  for (byte i = 0; i < size; i++) {
    Serial.print(uid[i] < 0x10 ? " 0" : " ");
    Serial.print(uid[i], HEX);
  }
}


Testing an RFID Blocking Wallet

One of the real reasons I built this was to test something completely different. I was sent a Mech Wallet from Punkube, which is not a sponsored mention, they just sent me the wallet for free. It is an aluminium alloy card wallet, and the claim I wanted to check was that it blocks RFID.

The wallet itself is nicely made. It fans the cards out to the side when you push them up, so you can pick the one you want easily. It came with a money clip that can be attached and a MagSafe plate you can screw on if you want to stick it to an iPhone, which I do not own, so that part is not much use to me. The wooden RFID cards turned out to be too thick to fit comfortably, but everything else slid in fine.

The test was simple. I took the one card that I knew opened the lock and held it against the reader inside the wallet. Nothing. No reaction at all. Slide the card out of the wallet, hold it against the reader again, and the relay clicks straight away.

It works for the reason you would expect once you know how the cards are powered. The metal body of the wallet blocks the radio field from ever reaching the card's coil, so no current is induced, the chip never powers up, and there is nothing for the reader to read. Given how many of us now carry contactless bank cards for absolutely everything, I am going to start using this one, because skimming does happen out in the wild and blocking the field is the most reliable way to stop it.


Conclusion

This is a crude demo and I would not put it on an actual front door in this state. It has no feedback beyond the serial monitor, so adding a buzzer for audible confirmation and a couple of LEDs to show the current state would be the obvious next improvements. But as a starting point it does the job. It reads cards, it learns new ones without a computer, it remembers them through a power cut, it forgets them on command, and it drives a relay you can wire to a real lock.

This is not the first time RFID has shown up on the channel either. I built a safety lock for my workshop that cuts power to every outlet using RFID, and I did a project working with a Google Wallet card, both of which go further into what these cards can store beyond just a UID. There is a lot more to explore here, because the memory inside these cards is genuinely useful once you start writing to it rather than only reading the ID.

If you enjoyed this build and want to see more projects like it, subscribe to my Taste The Code YouTube channel so you do not miss the next one.

You might also enjoy this

Helium-based DIY GPS vehicle tracker with RYS8839, RYLR993, and ESP32

I was recently introduced to the Helium network by a friend who discovered that it is locally available and people already installed antenna...