I already have the captive portal working. Now the problem I have is the "authorization/login", I want the client to access the internet after passing the portal page/conditions. How do you make the connected client authorized and able to access the connection? I am using NodeJs as web server. hostapd and dnsmasq


eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet  netmask  broadcast
        inet6 fe80::a00:27ff:fe64:8d48  prefixlen 64  scopeid 0x20<link>
        ether 08:00:27:64:8d:48  txqueuelen 1000  (Ethernet)
        RX packets 795  bytes 149308 (145.8 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 841  bytes 153133 (149.5 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet  netmask
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 20  bytes 1200 (1.1 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 20  bytes 1200 (1.1 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

wlan0: flags=4099<UP,BROADCAST,MULTICAST>  mtu 1500
        inet  netmask  broadcast
        ether ee:1f:08:9a:91:d0  txqueuelen 1000  (Ethernet)
        RX packets 684  bytes 140824 (137.5 KiB)
        RX errors 0  dropped 3  overruns 0  frame 0
        TX packets 1120  bytes 188344 (183.9 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0




Nodejs server

const express = require("express")
const app = express();
const morgan = require("morgan");
const path = require("path");


const indexPath = path.join(__dirname+"/index.html");

app.get('/generate_204', (req, res)=> { 
    console.log('generate 204 hit'); 
    res.statusCode = 302; 
    res.setHeader("Location", "/");

app.get("/", (req,res)=>{

    console.log("Server listen on ",80)


# Generated by iptables-save v1.8.8 (nf_tables) on Sun Sep 11 18:09:51 

-A FORWARD -i wlan0 -j ACCEPT
# Completed on Sun Sep 11 18:09:51 2022
# Generated by iptables-save v1.8.8 (nf_tables) on Sun Sep 11 18:09:51 2022
# Completed on Sun Sep 11 18:09:51 2022

sorry for the english I'm using google translate

  • Important advice: You should make sure to protect your box from the Internet to prevent others from accessing your system in ways that you haven't intended. As a first measure, set the default policies of both INPUT and FORWARD to DROP, then make sure that traffic from the inside can reach the 'Net. You'd want to drop invalid TCP packets on INPUT, however, you need to admit anything from the outside that belongs to established or related connections as well as any replies from UDP ports 53 (DNS) and 123 (NTP). The same is true for any forwarded traffic.
    – Robidu
    Sep 12, 2022 at 10:21

1 Answer 1


To get a captive portal up and running, a multi-step process is required.

Before you get started, I'd recommend to set up a name server on your box so that you can handle name resolution there (a forward-only server is entirely sufficient for this), and you also need a DHCP server set up and running on wlan0 (best set up according to RFC 8910 to avoid potential issues, especially if your captive portal is going to use HTTPS).

First of all, make sure that ipset is installed on your system (you are going to need this for granting authorized stations access to the 'Net). Next you need to create a table containing the IP addresses of all authorized stations. This will do the trick (best from /etc/rc.local or whatever early boot script is available in your distro):

/usr/sbin/ipset create AUTHORIZED hash:ip family inet timeout 7200

This is going to create a set that stores IP addresses for up to two hours (7200 seconds). These are your authorized addresses.

Next you need a back-end script that takes the IP addresses of a station (there should be a function in NodeJS that does exactly that) that intends to get access permissions to the 'Net and store that in the aforementioned set. With Perl I'd do it this way (here $station takes the IP address of the station requesting permission) - you will have to adapt that for NodeJS:

system("/usr/sbin/ipset AUTHORIZED $station");

Caution: If your NodeJS is unprivileged (i. e. not running as root), you will have to add an entry to sudoers that enables your script to call this command as ipset requires root privileges to work. You then need to make sure to register this command exactly as you are going to invoke it and prefix it with NOPASSWD: (this way sudo won't complain about a missing password when you try to add an IP address to the set). Also make sure to place /usr/bin/sudo at the front of the string!

The entry in sudoers should look like this one (use visudo to edit):

nodejs ALL=(ALL) NOPASSWD: /usr/sbin/ipset add AUTHORIZED *

You need to replace nodejs by the name of the user your NodeJS server is running as, otherwise the command is not going to work, either. Also, since your script is obtaining the IP address of the registering station by itself and doesn't rely on any user input, it's safe to use a wildcard here.

Then add these to your firewall script:

/usr/sbin/iptables -N captive_portal

# In the next statement change the port number to 443 if your captive portal is using HTTPS!
/usr/sbin/iptables -A INPUT -i wlan0 -p tcp --dport 80 -j ACCEPT
# The following line also requires a configured and running name server!
/usr/sbin/iptables -A INPUT -i wlan0 -p udp --dport 53 -j ACCEPT

/usr/sbin/iptables -A FORWARD -i wlan0 -j captive_portal

# If an IP address is not found in the set, refuse to forward it.
/usr/sbin/iptables -A captive_portal -m set ! --match-set AUTHORIZED src -j REJECT --reject-with icmp-admin-prohibited
# The following line is necessary to reset the timeout of a particular entry in the set so that active IP addresses aren't expired.
/usr/sbin/iptables -A captive_portal -j SET --add-set AUTHORIZED src --exist
/usr/sbin/iptables -A captive_portal -j ACCEPT

Afterwards your captive portal is good to go.

You can also extend this by including a logout page so instead of having to wait for an entry to expire after at most two hours, a station has an explicit option to immediately sign off.

In Perl this would be invoked like this (again $station contains the IP address of the station signing off):

system("/usr/sbin/ipset del AUTHORIZED $station");

Again, you may need an entry in sudoers to be able to invoke this command from your script.

nodejs ALL=(ALL) NOPASSWD: /usr/sbin/ipset del AUHORIZED *

Once you have everything up and running, you are good to go.

A side note concerning sudo:

If you have to pass any user input to a command requiring root privileges for whatever reason, either make sure to narrow that down by appropriate entries in sudoers or, if you absolutely have to use wildcards in that case, make sure to completely sanitize any and all user input and filter anything that could be potentially dangerous (keeping a whitelist of approved input is the most viable option here). Otherwise you are going to have a severe breach of security, up to the point that your system is rendered completely inoperable!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.