Cross-posted DEV blog post:, German version coming up in my Open Mind Culture webblog.

Collage of Deutsche Bahn captive page  and a browser notification that there is no internet.

What is a captive page and how to create one?

My issue: the expected “Sign into Wi-Fi network” prompt does not always appear automatically when I try to log into a public network that requires a login, like “WiFi on ICE” in Deutsche Bahn’s intercity trains. Sometimes computers fail or refuse to display that page, making it hard to use the internet while travelling or working in a caf√©.

Cannot reach public WIFI login page in Ubuntu, Captive Portal Issue, a post on, has been viewed 23k times! How can so many people visit that page if they don’t have internet? ūü§Ē

Screenshot of quoted post on

What is happening here?

Many systems like Android, ChromiumOS, Apple’s MacOS, or Microsoft Windows seem to have a built-in sign-in process that calls an external “captive page” like¬†

Ubuntu Linux doesn’t, which might be one of the few disadvantages of using Linux, although technically speaking, we could rather say the captive portals hijack people’s first internet requests, which is more easy to achieve on Windows and Apple devices than on Linux which mostly prioritizes security over convenience.

I tried to understand what is actually happening here and found it is not that simple and straightforward as it might seem on a first glance. Check Wikipedia and Chromium for more details.

A captive portal usually tries to capture your first website request using its DNS server, and if it doesn’t, your operating system’s connection manager will try to do so, at least on most operating system except Linux.

There is no Internet

Screenshot of Deutsche Bahn WiFi on ICE internet portal page and an error message that there is no internet

If it doesn’t work, you might see another vintage style website telling you that there is no internet. I can tell you as I’m living in Germany. Despite the material wealth of our country, we are quite poor in other aspects of life, maybe emotionally but surely technologically, as Germany is infamous for its slow and unskillful adoption of new technology and its relatively slow and unreliable internet connections.

Understanding Captive Pages and their Limitations

Wikipedia lists different kinds of issues that might break the automatic portal process:

Captive portals often require the use of a web browser; this is usually the first application that users start after connected to the Internet, but users who first use an email client or other application that relies on the Internet may find the connection not working without explanation, and will then need to open a web browser to validate. […] A similar problem can occur if the client uses AJAX or joins the network with pages already loaded into its web browser. Similarly, as HTTPS connections cannot be redirected (at least not without triggering security warnings), a web browser that only attempts to access secure websites before being authorized by the captive portal will see those attempts fail without explanation (the usual symptom is that the intended website appears to be down or inaccessible).

Building a Captive Page of our own

Wouldn’t it be cool to have our own¬†captive.localhost¬†or even a service like¬†¬†ready to go on any device?

Screenshot of Open Mind Culture captive page with network headers shown in the browser's developer tools

While the actual captive mechanisms can do more or less complicated things like access control and intercepting network traffic, all that we want to build is a page that will trigger this mechanism.

So “creating a captive page” that we can call in our web browser to trigger the actual captive mechanism, is in fact nothing but a page that accepts an unencrypted HTTP request and returns a 204 or 200 status code without redirecting to HTTPS.

Using PHP to generate a Simple Page and Set HTTP Headers

One possible way to return a website and control its HTTP headers is by using PHP, which might be the simplest way if you already have a PHP-based application like WordPress up and running on a web server. If you haven’t, you might prefer to deploy a node server to a cloud infrastructure or configure a “serverless” service.

Let’s assume we have a PHP server ready, we can create a new directory and configure a new subdomain that points to that folder where we will then put a file called¬†index.php that sets the appropriate response headers, like preventing our browser or web proxies to cache the page.

Sending any Successful 2xx HTTP Status

As Apple’s implementation shows, we don’t have to send a¬†204 No Content status. Many portals, like ICEportal of German long-distance trains, use their captive page to display a consent or login form, advertise their services (like entertainment content stored on a local server) or display status information (like the current train line and upcoming station). But we don’t have to bother putting too much content either, as we expect to get redirected to the real captive page once the external network intercepts our call and hijacks our first request anyway. Well, some do and some don’t, so let’s put some minimal content at least.


    if (!headers_sent()) {
        header('Status: 200 OK');
        header('Cache-Control: no-cache');
        header('Content-Type: text/html');
?><!DOCTYPE html>
<html lang="en">
More content here...

Nice! Now we have to prove that it actually works and does its job, right? Here’s the page, let’s see it in action:¬†

Everything seems to work as expected. No error, no redirect, no https in the first place, and an HTTP header to prevent caching.

Now all I have to do is test it “in the wild”, taking my Linux laptop outside and try to use it in a public network known to require a captive page like Deutsche Bahn’s WiFiOnICE or WiFi@DB.