Home / Projects / ESP32 Projects / ESP32 Captive Portal Rickroll: Create a Wi-Fi Prank

ESP32 Captive Portal Rickroll: Create a Wi-Fi Prank

pcbway

Have you ever connected to Wi-Fi at a hotel, airport, or coffee shop and had a webpage automatically appear? That webpage is called a captive portal. Captive portals are normally used for perfectly sensible things such as accepting terms of service, entering login credentials, or configuring an IoT device. We're going to use one for something considerably less productive. In this project, the ESP32 creates a Wi-Fi access point called "Free WiFi".

When someone connects, their phone detects the ESP32 captive portal and displays a convincing "Continue to Wi-Fi" button.  But pressing the button does not provide Internet access. Instead, it reveals a short Rickroll video stored directly in the ESP32's flash memory and starts playing it. No Internet connection is required. No YouTube redirect is required. The ESP32 itself hosts the entire prank.

How the ESP32 Captive Portal Rickroll Works

The project uses three main features of the ESP32:

  • Wi-Fi Access Point mode
  • DNS captive portal
  • HTTP web server

The architecture is much simpler than you might expect:

When a smartphone connects to a Wi-Fi network, the operating system normally performs an Internet connectivity check. If the expected response isn't received, the phone assumes that the Wi-Fi network requires authentication and opens a captive portal. Our ESP32 deliberately takes advantage of this behavior. A DNS server running on the ESP32 responds to requests by directing them back to the ESP32 itself. The ESP32 then serves our fake Wi-Fi connection page.

Why Store the Rickroll Video on the ESP32?

My first version of this experiment redirected the user to the actual Rick Astley video on YouTube. That created an interesting problem. The ESP32 access point needed to provide real Internet access, which meant simultaneously using AP and Station modes, enabling NAPT, forwarding DNS requests, and routing traffic through another Wi-Fi network. It worked, but it made a ridiculous little prank considerably more complicated than necessary.

There was another problem: modern browsers don't reliably autoplay YouTube videos. The better solution is to store a small video directly in the ESP32's flash memory.

Now the entire system is:

Phone
   |
Wi-Fi
   |
ESP32
   |
LittleFS
   |
rickroll.mp4

The ESP32 doesn't require Internet access at all. This also makes the project much more useful as an introduction to hosting static files from an ESP32.

What You Need

The hardware requirements are minimal:

  • ESP32 development board
  • USB cable
  • Computer with Arduino IDE
  • Smartphone for testing
  • Short MP4 video

No sensors, displays, or external components are required. For my test, I compressed an approximately eight-second clip to:

Resolution: 320 x 180
Frame rate: 15 FPS
Video: H.264
Audio: AAC mono
File size: about 259 KB

That is small enough to fit comfortably inside a suitable LittleFS partition on a typical 4 MB ESP32. For a project you redistribute publicly, use a short video that you have permission to distribute.

Arduino-ESP32 Version

This tutorial uses the newer Arduino-ESP32 networking API. I tested the project using esp32 core v. 3.3.11:

The code uses functions such as:

WiFi.AP.begin();

WiFi.AP.create();

WiFi.AP.enableDhcpCaptivePortal();

If you see errors such as:

'class WiFiClass' has no member named 'AP'

you are probably using an older Arduino-ESP32 2.x installation.

Open:

Tools
  -> Board
  -> Boards Manager

Search for:

esp32

and update esp32 by Espressif Systems to a compatible 3.x release.

Preparing the Video with LittleFS

Instead of putting the MP4 inside the Arduino sketch as a huge byte array, we're going to store it in LittleFS. LittleFS is a filesystem stored inside part of the ESP32's flash memory. Your Arduino project should look like this:

esp32_rick_roll/
|
+-- esp32_rick_roll.ino
|
+-- data/
    |
    +-- rickroll.mp4

The filename used by this tutorial is:

rickroll.mp4

and the ESP32 will access it as:

/rickroll.mp4

Selecting a Partition Scheme

Make sure your selected ESP32 partition layout provides enough filesystem space for the MP4. For a typical 4 MB ESP32, a partition scheme providing around 1.5 MB for the filesystem is more than enough for our roughly 259 KB clip. I used the default partition scheme for this project, which provides 1.5 MB for the LittleFS partition.

Uploading the MP4 to LittleFS

Place the rick roll video inside the sketch's data folder. Then use your Arduino IDE ESP32 LittleFS filesystem uploader to upload the contents of the folder.

ESP32 Captive Portal Rickroll Code

Here is the complete sketch.

#include <Arduino.h>
#include <WiFi.h>
#include <Network.h>
#include <DNSServer.h>
#include <WebServer.h>
#include <LittleFS.h>

const char* AP_SSID = "Free WiFi";
const char* VIDEO_PATH = "/rickroll.mp4";

DNSServer dnsServer;
WebServer server(80);

// ============================================================
// CAPTIVE PORTAL HTML
// ============================================================

const char portalPage[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<title>Free WiFi</title>

<style>
* {
  box-sizing: border-box;
}

html, body {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
  background: #111;
  font-family: Arial, Helvetica, sans-serif;
  color: white;
}

body {
  display: flex;
  align-items: center;
  justify-content: center;
}

#portal {
  width: 90%;
  max-width: 420px;
  padding: 35px 25px;
  text-align: center;
  background: #222;
  border-radius: 15px;
}

#portal h2 {
  margin-top: 0;
}

#continueButton {
  border: 0;
  border-radius: 8px;
  padding: 15px 30px;
  margin-top: 15px;
  font-size: 18px;
  font-weight: bold;
  background: white;
  color: #111;
  cursor: pointer;
}

#videoContainer {
  display: none;
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  background: black;
}

#rickVideo {
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: black;
}

#fallback {
  display: none;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 20px;
  text-align: center;
  color: white;
}
</style>
</head>

<body>
<div id="portal">
  <h2>Wi-Fi Connected!</h2>
  <p>Connection successful.</p>
  <button id="continueButton" onclick="activateRickroll()">Continue to Wi-Fi</button>
</div>

<div id="videoContainer">
  <video id="rickVideo" loop playsinline preload="auto">
    <source src="/rickroll.mp4" type="video/mp4">
  </video>
  <div id="fallback">Tap the video for sound</div>
</div>

<script>
const video = document.getElementById("rickVideo");
const portal = document.getElementById("portal");
const videoContainer = document.getElementById("videoContainer");
const fallback = document.getElementById("fallback");

async function activateRickroll() {
  portal.style.display = "none";
  videoContainer.style.display = "block";

  video.currentTime = 0;
  video.muted = false;
  video.volume = 1.0;

  try {
    await video.play();
  } catch (error) {
    fallback.style.display = "block";
  }
}

videoContainer.addEventListener("click", async function() {
  try {
    video.muted = false;
    video.volume = 1.0;
    await video.play();
    fallback.style.display = "none";
  } catch (error) {}
});
</script>
</body>
</html>
)rawliteral";

// ============================================================
// CAPTIVE PORTAL
// ============================================================

void showPortal() {
  server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate");
  server.send(200, "text/html", portalPage);
}

// ============================================================
// HTTP RANGE ERROR
// ============================================================

void sendRangeError(size_t fileSize) {
  server.sendHeader("Content-Range", String("bytes */") + String(fileSize));
  server.send(416, "text/plain", "");
}

// ============================================================
// STREAM PARTIAL VIDEO
// ============================================================

void streamFileRange(File& file, size_t start, size_t end) {
  size_t fileSize = file.size();

  if (start >= fileSize) {
    sendRangeError(fileSize);
    return;
  }

  if (end >= fileSize) {
    end = fileSize - 1;
  }

  if (end < start) {
    sendRangeError(fileSize);
    return;
  }

  size_t contentLength = end - start + 1;

  server.sendHeader("Accept-Ranges", "bytes");
  server.sendHeader(
    "Content-Range",
    String("bytes ") + String(start) + "-" + String(end) + "/" + String(fileSize)
  );
  server.sendHeader("Cache-Control", "public, max-age=3600");
  server.setContentLength(contentLength);
  server.send(206, "video/mp4", "");

  file.seek(start);

  NetworkClient client = server.client();
  uint8_t buffer[1024];
  size_t remaining = contentLength;

  while (remaining > 0 && client.connected()) {
    size_t amount = min(remaining, sizeof(buffer));
    size_t bytesRead = file.read(buffer, amount);

    if (bytesRead == 0) {
      break;
    }

    client.write(buffer, bytesRead);
    remaining -= bytesRead;
    delay(0);
  }
}

// ============================================================
// STREAM COMPLETE VIDEO
// ============================================================

void streamWholeFile(File& file) {
  size_t fileSize = file.size();

  server.sendHeader("Accept-Ranges", "bytes");
  server.sendHeader("Cache-Control", "public, max-age=3600");
  server.setContentLength(fileSize);
  server.send(200, "video/mp4", "");

  NetworkClient client = server.client();
  uint8_t buffer[1024];

  while (file.available() && client.connected()) {
    size_t bytesRead = file.read(buffer, sizeof(buffer));

    if (bytesRead == 0) {
      break;
    }

    client.write(buffer, bytesRead);
    delay(0);
  }
}

// ============================================================
// VIDEO REQUEST HANDLER
// ============================================================

void handleVideo() {
  if (!LittleFS.exists(VIDEO_PATH)) {
    Serial.println("ERROR: Video not found!");
    server.send(404, "text/plain", "Video not found");
    return;
  }

  File video = LittleFS.open(VIDEO_PATH, "r");

  if (!video) {
    server.send(500, "text/plain", "Could not open video");
    return;
  }

  String range = server.header("Range");

  if (range.length() > 0 && range.startsWith("bytes=")) {
    range.remove(0, 6);

    int dash = range.indexOf('-');

    if (dash >= 0) {
      String startString = range.substring(0, dash);
      String endString = range.substring(dash + 1);

      size_t start = startString.toInt();
      size_t end = endString.length() == 0
        ? video.size() - 1
        : endString.toInt();

      streamFileRange(video, start, end);
      video.close();
      return;
    }
  }

  streamWholeFile(video);
  video.close();
}

// ============================================================
// UNKNOWN HTTP REQUEST
// ============================================================

void handleNotFound() {
  server.sendHeader("Location", "/", true);
  server.send(302, "text/plain", "");
}

// ============================================================
// SETUP
// ============================================================

void setup() {
  Serial.begin(115200);
  delay(500);

  Serial.println();
  Serial.println("ESP32 Local Rickroll");

  // Mount LittleFS
  if (!LittleFS.begin(false)) {
    Serial.println("LittleFS mount failed!");
    return;
  }

  Serial.println("LittleFS mounted.");

  // Check video
  if (LittleFS.exists(VIDEO_PATH)) {
    File video = LittleFS.open(VIDEO_PATH, "r");

    Serial.print("Video found: ");
    Serial.print(video.size());
    Serial.println(" bytes");

    video.close();
  } else {
    Serial.println("WARNING: /rickroll.mp4 not found!");
  }

  // Start Wi-Fi access point
  WiFi.AP.begin();

  if (!WiFi.AP.create(AP_SSID)) {
    Serial.println("Could not create AP!");
    return;
  }

  Serial.print("WiFi AP: ");
  Serial.println(AP_SSID);

  Serial.print("IP address: ");
  Serial.println(WiFi.AP.localIP());

  WiFi.AP.enableDhcpCaptivePortal();

  // Start captive DNS
  if (dnsServer.start()) {
    Serial.println("Captive DNS started.");
  } else {
    Serial.println("DNS server failed!");
  }

  // Collect HTTP Range header for MP4 streaming
  const char* headerKeys[] = {"Range"};
  server.collectHeaders(headerKeys, 1);

  // Web routes
  server.on("/", HTTP_GET, showPortal);
  server.on("/rickroll.mp4", HTTP_GET, handleVideo);

  // Android
  server.on("/generate_204", HTTP_GET, showPortal);
  server.on("/gen_204", HTTP_GET, showPortal);

  // Apple
  server.on("/hotspot-detect.html", HTTP_GET, showPortal);

  // Windows
  server.on("/connecttest.txt", HTTP_GET, showPortal);
  server.on("/ncsi.txt", HTTP_GET, showPortal);
  server.on("/fwlink", HTTP_GET, showPortal);

  server.onNotFound(handleNotFound);
  server.begin();

  Serial.println("Web server started.");
  Serial.println();
  Serial.println("Connect to: Free WiFi");
}

// ============================================================
// LOOP
// ============================================================

void loop() {
  server.handleClient();
  delay(5);
}

How the Code Works

Let's break the project into its individual pieces.

Creating the ESP32 Wi-Fi Access Point

The ESP32 first starts its Access Point interface:

WiFi.AP.begin();

We then create the network:

WiFi.AP.create(
    "Free WiFi"
);

The ESP32 now appears in your phone's available Wi-Fi networks as:

Free WiFi

By default, the ESP32 access point normally uses an address similar to:

192.168.4.1

Triggering the Captive Portal

The next important line is:

WiFi.AP.enableDhcpCaptivePortal();

This advertises the ESP32's captive portal information through DHCP on compatible systems.

We also start:

dnsServer.start();

The DNS server responds to client DNS requests with the ESP32 access point address.

Conceptually:

example.com
     |
     v
ESP32 DNS Server
     |
     v
192.168.4.1

Modern phones also make their own connectivity-check requests after joining Wi-Fi. The sketch includes handlers for several common endpoints:

/generate_204
/gen_204
/hotspot-detect.html
/connecttest.txt
/ncsi.txt
/fwlink

Each one returns our captive portal page. The exact appearance and behavior depend on the operating system. You may see something similar to:

Sign in to Wi-Fi network

or the portal may appear automatically.

Why the Video Doesn’t Play Immediately

Earlier versions of this project used:

autoplay
muted
loop

inside the video element. That caused the Rickroll to appear as soon as the captive portal opened. That's not the behavior we want. The final version instead uses:

<video
  id="rickVideo"
  loop
  playsinline
  preload="auto">

There is no:

autoplay

attribute.

The video container itself is initially hidden:

#videoContainer {
    display: none;
}

So the victim initially sees only:

Wi-Fi Connected!

Connection successful.

[ Continue to Wi-Fi ]

The MP4 may be preloaded in the background, but it remains paused and invisible.

Starting the Rickroll with Sound

The button calls:

activateRickroll()

The first thing we do is hide the fake Wi-Fi page:

portal.style.display =
  "none";

Then reveal the video:

videoContainer.style.display =
  "block";

We restart it from the beginning:

video.currentTime = 0;

and enable audio:

video.muted = false;

video.volume = 1.0;

Finally:

await video.play();

The important detail here is that `play()` is being requested as the result of an actual button press. Modern mobile browsers generally place stricter restrictions on media that tries to autoplay with sound without any user interaction.

Our:

Continue to Wi-Fi

button provides that interaction. Some captive portal browsers may still impose additional restrictions, so the page also allows another tap directly on the video as a fallback.

Why the Code Supports HTTP Range Requests

You might notice that serving the MP4 is considerably more complicated than serving the HTML page. That's because browsers often don't request an entire video file in one operation. They may send an HTTP header similar to:

Range: bytes=0-65535

This means:

Send me only bytes 0 through 65535.

Our ESP32 recognizes this request and returns:

HTTP 206 Partial Content

together with the requested part of the MP4. Supporting byte ranges makes the ESP32 behave more like a proper video server and improves compatibility with HTML5 video players. For a tiny static webpage, none of this would be necessary. For an MP4 player, it's worth implementing correctly.

Testing the ESP32 Rickroll

Upload both:

  1. the Arduino sketch
  2. the LittleFS filesystem containing `rickroll.mp4`

Then open Serial Monitor at:

115200 baud

You should see something similar to:

ESP32 Local Rickroll

LittleFS mounted.

Video found: 265000 bytes

WiFi AP: Free WiFi

IP address: 192.168.4.1

Captive DNS started.

Web server started.

Connect to: Free WiFi

The exact video size will depend on your MP4. Now open Wi-Fi settings on your smartphone.

Connect to:

Free WiFi

Your phone should detect the captive portal. The page initially displays:

Wi-Fi Connected!

Connection successful.

[ Continue to Wi-Fi ]

Nothing suspicious yet.

Press:

Continue to Wi-Fi

The fake Wi-Fi page disappears and the locally hosted video starts. If the captive portal browser allows audio following the button press, you now have the full Rickroll.

Troubleshooting

The ESP32 says the video was not found

If Serial Monitor shows:

WARNING: /rickroll.mp4 not found!

make sure the filesystem contains:

/rickroll.mp4

Your local Arduino folder should contain:

data/rickroll.mp4

and you must upload the LittleFS filesystem separately from uploading the sketch.

LittleFS fails to mount

Check that the selected partition scheme includes filesystem storage. Also confirm that you uploaded a LittleFS image compatible with the partition layout currently selected.

LittleFS upload says COM port is busy

Close:

  • Arduino Serial Monitor
  • Serial Plotter
  • other Arduino IDE instances
  • PuTTY or other serial terminals

Then retry.

The captive portal doesn’t appear

Captive portal detection varies between devices. You can manually open:

http://192.168.4.1

while connected to:

Free WiFi

to verify that the web server itself is functioning.

The video appears before pressing Continue

Make sure your video element does not contain:

autoplay

and that:

#videoContainer {
    display: none;
}

is present. The video should only become visible inside:

activateRickroll()

after the button is pressed.

The video plays but has no sound

Some captive portal browsers impose stricter media restrictions than normal Chrome or Safari windows. The button press gives us the best opportunity to start audible playback, but behavior can still vary by device. The sketch therefore also lets the user tap the displayed video again to retry playback with sound.

Why This Project Is More Than Just a Prank

The Rickroll is obviously the least serious part of this project. Underneath it, we're demonstrating several useful ESP32 concepts:

  • Wi-Fi Access Point mode
  • DNS
  • captive portal detection
  • HTTP servers
  • LittleFS
  • serving static files from flash
  • HTML, CSS and JavaScript hosted by an ESP32
  • HTML5 video
  • HTTP byte-range requests

These same techniques can be used for much more practical projects. For example, an IoT device could create a temporary Wi-Fi network and use a captive portal for:

  • Wi-Fi configuration
  • device setup
  • sensor calibration
  • network diagnostics
  • configuration menus
  • firmware settings

LittleFS can similarly store:

  • HTML pages
  • CSS files
  • JavaScript
  • images
  • configuration files
  • audio
  • small videos

So although our application is deliberately ridiculous, the underlying techniques are quite useful.

A Note on Responsible Use

Use this project on your own devices or with people who are in on the joke. Captive portals can also be abused to imitate login pages and collect credentials.

This project doesn't ask the user for:

  • passwords
  • email addresses
  • account information
  • personal information

The fake Continue button simply starts a locally hosted video. Also be mindful of the media you distribute with your project. If you publish downloadable project files, use video and audio that you have permission to redistribute.

Conclusion

This may be one of the least necessary uses of an ESP32 I've built, but it's also a surprisingly good demonstration of the board's networking capabilities. The ESP32 creates its own Wi-Fi access point and uses DNS and DHCP captive portal features to encourage a connected smartphone to open a locally hosted webpage. The portal initially looks like an ordinary Wi-Fi connection screen.

Once the user presses the "Continue to Wi-Fi" button, JavaScript reveals an MP4 stored in LittleFS and requests playback with sound. Because everything is hosted directly on the ESP32, the project doesn't need an Internet connection, an external web server, or YouTube. All it takes is an ESP32, approximately 259 KB of questionable video content, and a victim willing to connect to suspiciously free Wi-Fi.

Never gonna give ESP32 up.