WiFi Captive Portal

A captive portal is a piece of software that prompts for user interaction before allowing the client to access the internet or other resources on the network. It is a combination of a firewall and a webserver. In this tutorial, I will explain how to create an open WiFi network. Before deploying an open WiFi network, you may want to consult a lawyer of the legality and restrictions for having one. You can also review what has been said by lawyers here and here.

When a device first connects to any network, it sends out a HTTP request and expects an HTTP status code of 200. If the device receives a HTTP 200 status code, it assumes it has unlimited internet access. Captive portal prompts are displayed when you are able to manipulate this first HTTP message to return a HTTP status code of 302 (redirect) to the captive portal of your choice.

To set up a captive portal on a Raspberry Pi, you will need a wired network (I will refer to this as WAN or uplink) and a wireless network (such as the Ralink RT5372 or the Ralink RT5370) which you can set into AP mode. A server with 2 NICs would also suffice if you want to perform this on a wired LAN instead.


UPDATE 2/15/2017: If you get the too many redirects error, look at the hotspot.localnet nginx configuration. It could be that the dollar signs $ are not present. The below block is what the location / block should look like in the hotspot.localnet virtual host.

     location / {
         try_files $uri $uri/ index.php;

Operating System

The operating system assumed will be Rapsbian Jessie. You can also follow this tutorial with a Debian server with two NICs – as long as one is dedicated to the WAN and the other to the LAN. It is also possible to use another Linux Distribution, but converting the commands will be up to you. In a later article, I will describe how to perform the steps on OpenWRT.

Required Packages

sudo apt-get install iptables-persistent conntrack dnsmasq nginx php5 php5-common php5-fpm hostapd

Configuring dnsmasq

You can certainly use other DNS and DHCP servers, but I like to use dnsmasq as it is lightweight and found on many routers already. This indicates to me that dnsmasq is pretty stable and able to perform in the enterprise.

The contents of my dnsmasq.conf are as follows:

# Never forward addresses in the non-routed address spaces.

# Add other name servers here, with domain specs if they are for
# non-public domains.

# Add local-only domains here, queries in these domains are answered
# from /etc/hosts or DHCP only.

# If you want dnsmasq to listen for DHCP and DNS requests only on
# specified interfaces (and the loopback) give the name of the
# interface (eg eth0) here.
# Repeat the line for more than one interface.

# Set the domain for dnsmasq. this is optional, but if it is set, it
# does the following things.
# 1) Allows DHCP hosts to have fully qualified domain names, as long
#     as the domain part matches this setting.
# 2) Sets the "domain" DHCP option thereby potentially setting the
#    domain of all systems configured by DHCP
# 3) Provides the domain part for "expand-hosts"

# Uncomment this to enable the integrated DHCP server, you need
# to supply the range of addresses available for lease and optionally
# a lease time. If you have more than one network, you will need to
# repeat this for each network on which you want to supply DHCP
# service.

# Override the default route supplied by dnsmasq, which assumes the
# router is the same machine as the one running dnsmasq.

#DNS Server

# Set the DHCP server to authoritative mode. In this mode it will barge in
# and take over the lease for any client which broadcasts on the network,
# whether it has a record of the lease or not. This avoids long timeouts
# when a machine wakes up on a new network. DO NOT enable this if there's
# the slightest chance that you might end up accidentally configuring a DHCP
# server for your campus/company accidentally. The ISC server uses
# the same option, and this URL provides more information:
# http://www.isc.org/files/auth.html

dnsmasq also reads in the /etc/hosts file and adds that information into the dns. Since we want to use pretty hostnames in our captive portal urls, we need to add a few entries to our hosts file.

# Contents of /etc/hosts	localhost	hotspot.localnet
::1		localhost ip6-localhost ip6-loopback
fe00::0		ip6-localnet
ff00::0		ip6-mcastprefix
ff02::1		ip6-allnodes
ff02::2		ip6-allrouters


Now that we have DNS and DHCP set up, we need to set up the interfaces. Edit /etc/network/interfaces and place in the following information:

# The loopback network interface
auto lo eth0
iface lo inet loopback

# The "wan" network interface
iface eth0 inet dhcp

# The "lan" network interface
iface wlan0 inet static

We also need to tell the kernel to forward IP packets between interfaces. To do that, enter the following command

echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf

You will need to reboot in order for that change to take effect, but do not do that quite yet.


hostapd will be the service that sets up the SSID and ensures it is broadcasting over the wireless card. If you are setting up the captive portal on a server with 2 NICs you can skip to the next section.

While you may certainly set up hostapd in WPA2 mode, I am assuming that because we are setting up a captive portal, this will be our authentication method rather than the encryption setting on the access point. To set up an open WiFi network, edit /etc/hostapd/hostapd.conf with the following information

ssid=MyOpenAP # name of the WiFi access point
channel=6 #use 1, 6, or 11

You will now have to set up the daemon by editing the contents of /etc/default/hostapd and change the line:





iptables is our firewall. This is how we are going to block all connections that we have not authorized. Below are the commands to set up the firewall:

# Turn into root
sudo -i
# Flush all connections in the firewall
iptables -F
# Delete all chains in iptables
iptables -X
# wlan0 is our wireless card. Replace with your second NIC if doing it from a server.
# This will set up our structure
iptables -t mangle -N wlan0_Trusted
iptables -t mangle -N wlan0_Outgoing
iptables -t mangle -N wlan0_Incoming
iptables -t mangle -I PREROUTING 1 -i wlan0 -j wlan0_Outgoing
iptables -t mangle -I PREROUTING 1 -i wlan0 -j wlan0_Trusted
iptables -t mangle -I POSTROUTING 1 -o wlan0 -j wlan0_Incoming
iptables -t nat -N wlan0_Outgoing
iptables -t nat -N wlan0_Router
iptables -t nat -N wlan0_Internet
iptables -t nat -N wlan0_Global
iptables -t nat -N wlan0_Unknown
iptables -t nat -N wlan0_AuthServers
iptables -t nat -N wlan0_temp
iptables -t nat -A PREROUTING -i wlan0 -j wlan0_Outgoing
iptables -t nat -A wlan0_Outgoing -d -j wlan0_Router
iptables -t nat -A wlan0_Router -j ACCEPT
iptables -t nat -A wlan0_Outgoing -j wlan0_Internet
iptables -t nat -A wlan0_Internet -m mark --mark 0x2 -j ACCEPT
iptables -t nat -A wlan0_Internet -j wlan0_Unknown
iptables -t nat -A wlan0_Unknown -j wlan0_AuthServers
iptables -t nat -A wlan0_Unknown -j wlan0_Global
iptables -t nat -A wlan0_Unknown -j wlan0_temp
# forward new requests to this destination
iptables -t nat -A wlan0_Unknown -p tcp --dport 80 -j DNAT --to-destination
iptables -t filter -N wlan0_Internet
iptables -t filter -N wlan0_AuthServers
iptables -t filter -N wlan0_Global
iptables -t filter -N wlan0_temp
iptables -t filter -N wlan0_Known
iptables -t filter -N wlan0_Unknown
iptables -t filter -I FORWARD -i wlan0 -j wlan0_Internet
iptables -t filter -A wlan0_Internet -m state --state INVALID -j DROP
iptables -t filter -A wlan0_Internet -o eth0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
iptables -t filter -A wlan0_Internet -j wlan0_AuthServers
iptables -t filter -A wlan0_AuthServers -d -j ACCEPT
iptables -t filter -A wlan0_Internet -j wlan0_Global
# allow access to my website :)
iptables -t filter -A wlan0_Global -d andrewwippler.com -j ACCEPT
#allow unrestricted access to packets marked with 0x2
iptables -t filter -A wlan0_Internet -m mark --mark 0x2 -j wlan0_Known
iptables -t filter -A wlan0_Known -d -j ACCEPT
iptables -t filter -A wlan0_Internet -j wlan0_Unknown
# allow access to DNS and DHCP
# This helps power users who have set their own DNS servers
iptables -t filter -A wlan0_Unknown -d -p udp --dport 53 -j ACCEPT
iptables -t filter -A wlan0_Unknown -d -p tcp --dport 53 -j ACCEPT
iptables -t filter -A wlan0_Unknown -d -p udp --dport 67 -j ACCEPT
iptables -t filter -A wlan0_Unknown -d -p tcp --dport 67 -j ACCEPT
iptables -t filter -A wlan0_Unknown -j REJECT --reject-with icmp-port-unreachable
#allow forwarding of requests from anywhere to eth0/WAN
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

#save our iptables
iptables-save > /etc/iptables/rules.v4


Now we need to set up nginx to send a magic packet that prompts the user for action (i.e MyOpenAP requires you to sign in). To do that, we will run the following commands:

# Make the HTML Document Root
mkdir /usr/share/nginx/html/portal
chown nginx:www-data /usr/share/nginx/html/portal
chmod 755 /usr/share/nginx/html/portal

# create the nginx hotspot.conf file
cat << EOF > /etc/nginx/sites-available/hotspot.conf
server {
    # Listening on IP Address.
    # This is the website iptables redirects to
    listen       80 default_server;
    root         /usr/share/nginx/html/portal;

    # For iOS
    if ($http_user_agent ~* (CaptiveNetworkSupport) ) {
        return 302 http://hotspot.localnet/hotspot.html;

    # For others
    location / {
        return 302 http://hotspot.localnet/;

 upstream php {
    #this should match value of "listen" directive in php-fpm pool
		server unix:/tmp/php-fpm.sock;

server {
     listen       80;
     server_name  hotspot.localnet;
     root         /usr/share/nginx/html/portal;

     location / {
         try_files $uri $uri/ index.php;

    # Pass all .php files onto a php-fpm/php-fcgi server.
    location ~ [^/]\.php(/|$) {
    	fastcgi_split_path_info ^(.+?\.php)(/.*)$;
    	if (!-f $document_root$fastcgi_script_name) {
    		return 404;
    	# This is a robust solution for path info security issue and works with "cgi.fix_pathinfo = 1" in /etc/php.ini (default)
    	include fastcgi_params;
    	fastcgi_index index.php;
    	fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    	fastcgi_pass php;


# Enable the website and reload nginx
ln -s /etc/nginx/sites-available/hotspot.conf /etc/nginx/sites-enabled/hotspot.conf
systemctl reload nginx

We have just set up the path /usr/share/nginx/html/portal to serve web pages if accessed via IP (note the default_server directive) as well as if accessed by the hostname hotspot.localnet. If accessed via IP, it will trigger a 302 redirect to the hostname hotspot.localnet.


When it comes to the coding aspect, we only want to allow access if the webserver has determined the user has clicked the I agree to the terms button. To do that, I will add a <input type="hidden" name="security_code" value="andrew-wippler-is-cool" /> to my form field. This will stop a few script kiddies from gaining access without clicking the button, but one may want to add additional security features if deploying this in the enterprise (or just buy an appliance that has a captive portal already).

We will need two files added to /usr/share/nginx/html/portal. They are the following:

<!DOCTYPE html>

// Grant access if the Security code is accurate.
if ($_POST['security_code'] == "andrew-wippler-is-cool") {

// Grab the MAC address
$arp = "/usr/sbin/arp"; // Attempt to get the client's mac address
$mac = shell_exec("$arp -a ".$_SERVER['REMOTE_ADDR']);
preg_match('/..:..:..:..:..:../',$mac , $matches);
$mac2 = $matches[0];

// Reconnect the device to the firewall
exec("sudo rmtrack " . $_SERVER['REMOTE_ADDR']);
$i = "sudo iptables -t mangle -A wlan0_Outgoing  -m mac --mac-source ".$_GET['mac']." -j MARK --set-mark 2";


?> <html>
<h1>You are now free to browse the internet.</h1>
</body> </html>
<?php } else {
  // this is what is seen when first viewing the page
  <h1>Authorization Required</h1>
  <p>Before continuing, you must first agree to the <a href="#">Terms of Service</a> and be of the legal age to do that in your selective country or have Parental Consent.
  <form method="post" action="index.php">
    <input type="hidden" name="security_code" value="andrew-wippler-is-cool" />
    <input type="checkbox" name="checkbox1" CHECKED /><label for="checkbox1">I Agree to the terms</label><br />
    <input type="submit" value="Connect" />
  </body> </html>
<?php } ?>
 <?xml version="1.0" encoding="UTF-8"?>
 <WISPAccessGatewayParam xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.wballiance.net/wispr_2_0.xsd">
 <AccessLocation>Andrew Wippler is awesome</AccessLocation>

Additional Configurations

We are almost done; however, you may have noticed the line 14 in index.php which includes executing rmtrack. What is rmtrack? It is the code of Andy Beverley which is licensed under the GNU Free Documentation License 1.3. In Andy’s tutorial for a captive portal, rmtrack is used to remove the connection information in the kernel to prevent login loops. Let’s create that file now:

cat << EOF > /usr/bin/rmtrack
/usr/sbin/conntrack -L \
    |grep $1 \
    |grep ESTAB \
    |grep 'dport=80' \
    |awk \
        "{ system(\"conntrack -D --orig-src $1 --orig-dst \" \
            substr(\$6,5) \" -p tcp --orig-port-src \" substr(\$7,7) \" \
            --orig-port-dst 80\"); }"

chmod +x /usr/bin/rmtrack

We also have to allow our web server user www-data access to run privileged commands. We can do that by typing visudo and appending the below lines to the file.

www-data ALL = NOPASSWD: /usr/bin/rmtrack [0-9]*.[0-9]*.[0-9]*.[0-9]*
www-data ALL = NOPASSWD: /sbin/iptables -t mangle -A wlan0_Outgoing  -m mac --mac-source ??\:??\:??\:??\:??\:?? -j MARK --set-mark 2

With the sudoers file saved, you are now ready to reboot the Raspberry Pi and enjoy offering a fairly simplistic captive portal.


    Really nice setup – I must try it on my pi – thanks for writing it up!
    One improvement:
    Instead of piping through grep like this:
    [/code]/usr/sbin/conntrack -L \
    |grep $1 \
    |grep ESTAB \
    |grep ‘dport=80’ \
    |awk \
    use just awk:
    [code]/usr/sbin/conntrack -L| awk -v IP=$1 ‘/IP/ && /ESTABLI/ && /dport=80/ {…}'[/code]


      Thanks. I updated the article with the changes.


    I wonder if you can do this using 2 ethernet cards and acces point
    I would add the second ethernet card via USB and card that would connect the access point
    Why I want to do this?
    an access point has greater coverage .
    sorry english is not my native languaje, i used google traslate.


      Yes, you can do it with any combination of interfaces. You would need to bond all interfaces in the LAN portion.


    Hello, how allow HTTPS website in iptables ?

    because facebook.com don’t work ?

    Thanks for advance

    Paul Montague

    Hi Andrew, this is just what I was looking for and have run through the setup on a Pi 3. Get a challenge on devices connecting to the WiFi but with a “403 Forbidden” error.
    Have traced this through nginx var logs and also by doing an strace on the nginx processes.
    Var Log: ….directory index of “/usr/share/nginx/html/portal/” is forbidden, client:, server: hotspot.localnet, request: “GET / HTTP/1.1”, host: “hotspot.localnet”
    Strace: [pid 918] stat64(“/usr/share/nginx/html/portal/index.html”, 0x7efa97f0) = -1 ENOENT (No such file or directory)
    Have tried opening up permissions on the portal directory but looks like an issue with nginx or php sourcing a non-existent file?

    Would appreciate any help.


      It looks like either there is no index.html file created or the permissions on index.html need to be redone.

    Paul Montague

    Thanks for your response. I only have two files in that directory, hotspot.html and index.php. Should I rename one of them?

    Paul Montague

    I renamed index.php to index.html and now get the “Authorization Required” screen on connection to the WiFi.
    What would the index.php look like that is being called by the index.html?


      Same here… what about index.php? Thanks.

    Andreas Wittmann

    Hello Andrew, nice project. May you can help me, i got an error “too many redirects”? Would appreciate any help.


      It seems you have a redirect loop going on. It could be that by clearing your browser’s cache would fix it. It could also be an issue with how the hostnames and redirects you have set up.

      I had similar issues when setting it up initially. The browser cache was the most common resolution, but I also had misconfigurations.

      This guide was written without any misconfigurations. The only items that need to be changed are the dns entries if you are deploying it to your home network.

    Levi Pihema-Lindsay

    So how would we intercept the first request and forward them to the gateway so as to get them to auth?


      When a device first connects to a new wireless access point, it sends out a HTTP request to its favorite URL. Following this guide will alter that first URL/request and give a redirect to the captive portal. You might want to run a packet capture on a laptop when you first connect to the RPi. You will see what I mean.


    hi will this work with 2 wireless card ?


      I have not tried it with 2 wireless NICs. I suppose it could work.

    Levi Pihema-Lindsay

    Hm. the redirect doesn’t seem to be working. Any ideas? everything just times out


      it won’t work on Https websites. try going to a non-https websites.

        Levi Pihema-Lindsay

        Yes, I know this. However, even on sites that don’t have SSL, I still timeout


    hi, is there a way to redirect or force open the fake_portal once user connects to the rogue_AP? Something like how mc’donalds or hotel wifi works .. once you connect to their wifi it redirects and opens up their portal …

    M. M.

    Very well written and described.

    Is it also possible to just make a splash page that opens when the user opens browser for the first time and\or every time?


      Like no internet access?… Just remove the button on the portal. The traffic flow has to stop for the captive portal to trigger. The button on the captive portal in my tutorial tags the traffic in the firewall to bypass the stopping point.

        M. M.

        Thanks for your response.
        I know it’s a question off the topic because what I mean is a splash page and not a Cuptive portal.
        I actually want the user to have internet access but see a splash page the first time they open browser or maybe every time.


          Unfortunately that is not possible. For a splash page to appear, internet traffic flow has to stop.


        hi, is there a way to redirect or force open the fake_portal once user connects to the rogue_AP? Something like how mc’donalds or hotel wifi works .. once you connect to their wifi it redirects and opens up their portal …


    Hello Andrew, thank you for this great tutorial! I am completely new to this captive portal. I hope you could answer my few questions. 🙂 Very much appreciate in advance!

    1. Do I connect my Pi to the router or modem? If it is connected to the router, so it should be wired, correct? Then the USB WIFI adapter will act as the access point on the Pi? I have Edimax WIFI USB Adapter but it does not give out AP as “MyOpenAP” after configured.

    2. I have this error “Job for nginx.service failed. See ‘systemctl status nginx.service’ and ‘journalctl -xn’ for details.” after entering this line “systemctl reload nginx” How can I fix this?

    3. After this line “chown nginx:www-data /usr/share/nginx/html/portal”, I got an error saying invalid user. Should it just be “chown www-data /usr/share/nginx/html/portal”?

    4. Can I just copy all your config settings and paste to my files? or do I have to modify to my router’s gateway interface (



      btw, I ran the code “journalctl -xn”, this was the log file that came up:
      “– Logs begin at Fri 2016-12-02 02:05:01 CST, end at Fri 2016-12-02 02:30:13 CST. —
      Dec 02 02:30:05 raspberrypi sshd[8021]: PAM 2 more authentication failures; logname= uid=0 euid=0 tty=ssh ruser= rhost= us
      Dec 02 02:30:06 raspberrypi sshd[8019]: Received disconnect from 11: [preauth]
      Dec 02 02:30:06 raspberrypi sshd[8019]: PAM 2 more authentication failures; logname= uid=0 euid=0 tty=ssh ruser= rhost= us
      Dec 02 02:30:06 raspberrypi sshd[8023]: Failed password for root from port 11105 ssh2
      Dec 02 02:30:06 raspberrypi sshd[8024]: Failed password for root from port 12941 ssh2
      Dec 02 02:30:06 raspberrypi sshd[8023]: Received disconnect from 11: [preauth]
      Dec 02 02:30:06 raspberrypi sshd[8023]: PAM 2 more authentication failures; logname= uid=0 euid=0 tty=ssh ruser= rhost= us
      Dec 02 02:30:06 raspberrypi sshd[8024]: Received disconnect from 11: [preauth]
      Dec 02 02:30:06 raspberrypi sshd[8024]: PAM 2 more authentication failures; logname= uid=0 euid=0 tty=ssh ruser= rhost= us
      Dec 02 02:30:13 raspberrypi sshd[8092]: fatal: Unable to negotiate a key exchange method [preauth]”

      And after this code “systemctl status nginx”. here is the response:
      “● nginx.service – A high performance web server and a reverse proxy server
      Loaded: loaded (/lib/systemd/system/nginx.service; enabled)
      Active: active (running) since Fri 2016-12-02 02:05:15 CST; 23min ago
      Process: 692 ExecStart=/usr/sbin/nginx -g daemon on; master_process on; (code=exited, status=0/SUCCESS)
      Process: 544 ExecStartPre=/usr/sbin/nginx -t -q -g daemon on; master_process on; (code=exited, status=0/SUCCESS)
      Main PID: 701 (nginx)
      CGroup: /system.slice/nginx.service
      ├─701 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
      ├─702 nginx: worker process
      ├─703 nginx: worker process
      ├─704 nginx: worker process
      └─705 nginx: worker process”


        For #2 Check what’s wrong loading nginx config files with:
        $ sudo nginx -t

        I had similar problem


    Hello Andrew, thanks for this tutorial. Just a minor problem, it seems that allowing any websites on the iptables rules doesn’t work. Can you help me with this one?


    Cool project! You mentioned that an appliance might be an alternative. Have you seen any that you would recommend?


      Ubiquiti offers a hotspot redirection with their UniFi software.

    Hugo Freire

    Hi Andrew thank you for this great tutorial and Happy New Year for you, with that configurations in the nginx server you can have a captive portal popup in the ios 9/10?
    I cant have it in any device and i google it very hard and i cant understand why, im gratefoul if you have any tip for me 🙂

    Sry for my bad english,
    Thanks and Best Regards

    Andre Metelo

    Just to let folks know, you can have https routed to the server through 3 steps:
    Step 1:
    Add a rule to forward port 443 to the https port int he nginx server
    – iptables -t nat -A wlan0_Unknown -p tcp –dport 443 -j DNAT –to-destination
    Remember to save the iptables again

    Step 2:
    – Create a SSL certificate for the NGINX

    sudo mkdir /etc/nginx/ssl
    sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/nginx/ssl/nginx.key -out /etc/nginx/ssl/nginx.crt

    Step 3:
    Add the listen 443 SSL;
    poiint the cetificates to the files created on page 2.

    That will be enough to route any https resquests to the https port, which then will redirect to http://hotspot.localnet/

    That’s it.

    PS – Thanks for putting this guide together. It was extremely useful for me to put one of those together.


      But won’t the users browser’s complain when they enter https websites like twitter?


    Hi. I have a problem, my captive portal redirect just when my file is html, whit .php file doesn’t works :S
    U know why can be this error.
    Thanks 😉



    can this method login using account in radius?



    Hello How can I make the same way but without two interfaces. Just plug Pi with dhcp server to TP-Link with wan Internet and disable DHCP on TP-link router?


      You would use VLans. You need to make the RPi the main router. A bit advanced, but do-able if you know what you are doing.


    Hello Andrew
    This was really helpful.The only problem I am facing now is in connecting my device to the WiFi.The SSID is being displayed and its being shown as an open network .Why am I unable to connect to the network ?


    Don’t know why but
    exec(“sudo rmtrack ” . $_SERVER[‘REMOTE_ADDR’]);
    within the php script results in endless loading for me.
    I, however, can do
    on my console without any problem.
    I added
    www-data ALL = NOPASSWD: /usr/bin/rmtrack [0-9]*.[0-9]*.[0-9]*.[0-9]*
    to the corresponding sudoers file and the rest works but not that.
    Any idea? I already tried to
    > /dev/null &
    the output, it looks like the awk part makes a problem to be executed from within php.

Add to the conversation:

Your email address will not be published. Required fields are marked *