A captive portal is the login page you hit when connecting to public WiFi hotels, airports, conference venues, cafes. You connect, open a browser, and instead of your destination, you see a terms-and-conditions page or a login form.

The mechanism seems simple: intercept traffic, show a page, let them through. But the gap between "works on my phone" and "works on every device" is vast. Different operating systems handle captive portal detection differently, and sometimes the exact same OS version behaves differently on two devices from the same manufacturer.

This post breaks down the architecture dnsmasq for DNS hijacking, nginx for HTTP interception, and a Node.js server for session management along with the device-specific quirks I encountered across iOS, Android, Windows, and Linux, and the patterns that made the system reliable in production.

How Captive Portals Work: The Redirection Flow

At its core, a captive portal is a DNS and HTTP interception system with three phases:

  1. Pre-authentication: All traffic is intercepted and redirected to the portal

  2. Authentication: User completes a flow (login, accept T&C, pay)

  3. Post-authentication: Traffic flows freely to the internet

Client → DNS query (any domain) → dnsmasq → resolves to gateway IP Client → HTTP request (any domain) → nginx → 302 redirect → captive portal page Client → follows redirect → sees login page After auth → iptables bypass rule added → traffic passes through 

Phase 1: DNS Hijacking with dnsmasq

When a client connects to the WiFi network, DHCP assigns it an IP, gateway, and DNS server all pointing to the captive portal server itself. dnsmasq runs as both the DHCP server and DNS resolver.

The critical config is the wildcard address rule:

# /etc/dnsmasq.conf interface=wlan0 dhcp-range=192.168.1.2,192.168.1.254,255.255.255.0,12h dhcp-option=3,192.168.1.1 dhcp-option=6,192.168.1.1 # Resolve ALL domains to the captive portal server address=/#/192.168.1.1 

address=/#/192.168.1.1 is the captive portal's foundation. Every DNS query google.com, apple.com, microsoft.com, any-domain.xyz returns 192.168.1.1. The client has no way to reach the actual internet because it can't resolve any real IP addresses.

This directive matches all domains for A-record (IPv4) queries. AAAA (IPv6) queries get :: by default. For captive portals running on IPv4-only networks, this is sufficient. If you're dual-stack, you'd add a similar rule for AAAA records.

There's a tension with the wildcard approach though. Some OS-level detection relies on specific DNS responses Windows NCSI, for example. If your wildcard hijacks dns.msftncsi.com, Windows assumes there's no internet and never sends the HTTP probe. You'd think the cleanest approach is the wildcard, but in practice you sometimes need exceptions for OS-specific probe domains.

Phase 2: HTTP Interception with nginx

With all DNS resolving to the local server, every HTTP request hits nginx. The default server catches everything and redirects to the captive portal:

# /etc/nginx/sites-available/captive-portal server { listen 80 default_server; server_name _; location / { return 302 http://captive.portal.local/login?dst=$scheme://$host$request_uri; } } 

Operating systems send captive portal detection probes requests to specific URLs to check if they've been redirected. If your nginx returns a 302 for every URL including these probes, the OS detects the portal and shows the notification.

The real problem is post-authentication. After a user authenticates, the OS sends these same probes again. If you're still redirecting, the OS thinks the portal is still blocking traffic and shows "Sign in to network" repeatedly. Your authenticated users see this notification forever.

You need dedicated handlers that return success responses for authenticated clients, while redirecting unauthenticated ones to the portal:

# /etc/nginx/sites-available/os-probes server { listen 80; server_name captive.apple.com www.apple.com; location /hotspot-detect.html { proxy_pass http://127.0.0.1:3000/check-apple; } } server { listen 80; server_name connectivitycheck.gstatic.com www.gstatic.com; location /generate_204 { proxy_pass http://127.0.0.1:3000/check-android; } } server { listen 80; server_name www.msftconnecttest.com msftconnecttest.com; location /connecttest.txt { proxy_pass http://127.0.0.1:3000/check-windows; } } # Catch-all: generate_204 requests from various Android builds location = /generate_204 { proxy_pass http://127.0.0.1:3000/check-generic; } 

Each OS expects a specific response:

OS

Probe URL

Expected Response

iOS

captive.apple.com/hotspot-detect.html

200 OK + <HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>

Android

connectivitycheck.gstatic.com/generate_204

204 No Content

Samsung

captive.samsung.com

204 No Content

Windows DNS

dns.msftncsi.com → A 131.107.255.255

Valid DNS response (don't hijack)

Windows HTTP

msftconnecttest.com/connecttest.txt

200 OK + Microsoft Connect Test

Linux

connectivity-check.gnome.org

204 No Content

Phase 3: Session Management with Node.js

The Node.js server handles authentication, session tracking, and communicates with iptables to grant or revoke access.

import express from 'express'; import { exec } from 'child_process'; const app = express(); const sessions = new Map<string, Session>(); interface Session { mac: string; ip: string; sessionToken: string; createdAt: number; expiresAt: number; } // Login page server-rendered, no JS dependency app.get('/login', (req, res) => { res.send(` <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>WiFi Access</title> <style> body { font-family: -apple-system, sans-serif; max-width: 400px; margin: 2em auto; padding: 0 1em; text-align: center; } button { background: #007aff; color: white; border: none; padding: 1em 2em; border-radius: 8px; font-size: 1.1em; cursor: pointer; } </style> </head> <body> <h1>Welcome to Guest WiFi</h1> <p>Accept the terms to get online.</p> <form action="/auth" method="POST"> <button type="submit">Accept & Connect</button> </form> </body> </html> `); }); // Authentication handler app.post('/auth', async (req, res) => { const clientIp = req.ip; const clientMac = await getMacByIp(clientIp); const sessionToken = crypto.randomUUID(); const session: Session = { mac: clientMac, ip: clientIp, sessionToken, createdAt: Date.now(), expiresAt: Date.now() + 24 * 60 * 60 * 1000, }; sessions.set(sessionToken, session); await addIptablesBypass(clientIp, clientMac); res.cookie('session', sessionToken, { httpOnly: true, sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000, }); // Don't redirect to destination this causes the redirect problem res.redirect('/connected'); }); // OS detection probe handlers app.get('/check-apple', async (req, res) => { const authed = await isAuthenticated(req); if (authed) { res.type('text/html'); res.send('<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>'); } else { res.redirect('/login'); } }); app.get('/check-android', async (req, res) => { const authed = await isAuthenticated(req); res.status(authed ? 204 : 302).end(); }); app.get('/check-windows', async (req, res) => { const authed = await isAuthenticated(req); if (authed) { res.type('text/plain').send('Microsoft Connect Test'); } else { res.redirect('/login'); } }); async function isAuthenticated(req: express.Request): Promise<boolean> { const sessionToken = req.cookies?.session; if (!sessionToken) return false; const session = sessions.get(sessionToken); if (!session || Date.now() > session.expiresAt) { sessions.delete(sessionToken); return false; } return true; } 

The iptables Bypass

Traffic separation requires careful iptables management. The FORWARD chain controls whether client traffic reaches the internet. The PREROUTING chain controls redirects to the captive portal.

# Initial setup redirect all traffic to captive portal iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 80 -j REDIRECT --to-port 8080 iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 443 -j REDIRECT --to-port 8443 iptables -A FORWARD -i wlan0 -j DROP # After auth insert bypass rules BEFORE the redirect iptables -t nat -I PREROUTING -m mac --mac-source AA:BB:CC:DD:EE:FF -j RETURN iptables -I FORWARD -m mac --mac-source AA:BB:CC:DD:EE:FF -j ACCEPT 

The -I (insert) puts the bypass at the top of the chain. The -A (append) redirect rule is at the bottom. When a matching MAC appears, iptables returns early and skips the redirect. This approach means you never need to remove and re-add the redirect rules only the exception rules.

async function addIptablesBypass(ip: string, mac: string): Promise<void> { const commands = [ `iptables -t nat -C PREROUTING -m mac --mac-source ${mac} -j RETURN 2>/dev/null || iptables -t nat -I PREROUTING -m mac --mac-source ${mac} -j RETURN`, `iptables -C FORWARD -m mac --mac-source ${mac} -j ACCEPT 2>/dev/null || iptables -I FORWARD -m mac --mac-source ${mac} -j ACCEPT`, ]; for (const cmd of commands) { await exec(cmd, { timeout: 5000 }); } } async function removeIptablesBypass(ip: string, mac: string): Promise<void> { const commands = [ `iptables -t nat -D PREROUTING -m mac --mac-source ${mac} -j RETURN`, `iptables -D FORWARD -m mac --mac-source ${mac} -j ACCEPT`, ]; for (const cmd of commands) { await exec(cmd, { timeout: 5000 }).catch(() => {}); } } 

The Device Problem: OS-by-OS Breakdown

After deploying this across real venues, here's what actually happened with each OS.

iOS: The Gold Standard

iOS was by far the most reliable and easiest platform to support. The captive portal detection is consistent, predictable, and well-documented. Every iOS device I tested behaved the same way.

The flow:

  1. Device connects to WiFi

  2. iOS probes captive.apple.com/hotspot-detect.html

  3. Gets a 302 redirect → Captive Network Assistant (CNA) opens automatically

  4. CNA loads the portal login page

  5. User accepts terms → iOS re-probes

  6. Gets the success response → CNA dismisses, "Connected" shown

The CNA has limitations no JavaScript, no localStorage, no cookies from Safari. But once you know these, you design around them and it just works. No surprises.

The CNA is essentially a Safari WebView with JavaScript disabled. It can render HTML forms, follow redirects (up to a point), and handle cookies set during the current CNA session. It just can't run JS, so your React/Vue login page won't work. Server-rendered HTML forms work fine.

One caveat: iOS has a redirect limit in the CNA. If your portal chain involves multiple redirects (captive.apple.com → custom domain → HTTPS fallback → login page), the CNA gives up after one or two hops. Keep the redirect chain flat: captive.apple.com → login page. That's it.

Android: The Unpredictable One

Android was the hardest. Not because the protocol is complex, but because behavior varies wildly across devices, manufacturers, and OS versions. I've seen a Pixel 7 handle the portal perfectly while a Samsung Galaxy S23 on the same Android version kept re-triggering the captive portal notification in a loop.

The 204 problem: Returning 204 No Content from generate_204 after authentication is the documented approach. On most devices it works. But some devices particularly Samsung and Xiaomi keep polling generate_204 every few seconds even after getting a 204. On these devices, the captive portal notification pops up repeatedly, dismissing and reappearing in a loop. The issue isn't that the 204 is wrong it's that the device's connectivity checker doesn't accept it as a final answer and keeps re-verifying.

The inconsistent detection problem: Some Android devices reliably detect the captive portal on connection. Others especially Chinese OEMs with custom Android builds don't trigger the portal notification at all. The user connects to WiFi, the device shows "Connected", but all HTTP traffic is blocked. The user has no idea they need to open a browser and navigate to an HTTP URL manually.

This happens because manufacturers customize the captive portal detection logic. Google's AOSP implementation checks connectivitycheck.gstatic.com/generate_204, but manufacturers like Samsung, Xiaomi, Huawei, and OnePlus override this with their own servers. If your nginx only handles the standard Google URL but not the Samsung URL, Samsung devices won't detect the portal.

The fix wasn't elegant: I had to handle every manufacturer probe URL I could find and accept that some devices would need manual intervention. The manufacturer probe URL list grew over time as new devices appeared in the logs:

Manufacturer

Probe URL

Google/AOSP

connectivitycheck.gstatic.com/generate_204

Samsung

captive.samsung.com

Xiaomi

connectivitycheck.mi.com/generate_204

Honor/Huawei

connectivitycheck.platform.hihonor.com/generate_204

OnePlus

connectivitycheck.oneplus.com/generate_204

Each of these needs to return 302 pre-auth and 204 post-auth. Miss one, and users of that brand see a broken experience.

Windows: NCSI and Done

Windows uses the Network Connectivity Status Indicator (NCSI) with a two-phase check:

  1. DNS probe: Resolves dns.msftncsi.com → expects address 131.107.255.255

  2. HTTP probe: Fetches http://www.msftconnecttest.com/connecttest.txt → expects body Microsoft Connect Test

If you return the wrong DNS response for dns.msftncsi.com, Windows assumes there's no internet and never sends the HTTP probe. The WiFi icon shows "No internet, secured" and users don't know to open a browser.

The fix is simple: don't hijack dns.msftncsi.com:

# dnsmasq exception for Windows NCSI server=/msftncsi.com/8.8.8.8 

And handle the HTTP probe URL:

server=/msftconnecttest.com/8.8.8.8 

Once these were configured correctly, Windows worked consistently across all versions (10, 11, Server). It was the least problematic platform after initial setup. We didn't touch Windows handling after the first deployment.

Linux: Also Just Works

Linux desktop environments use a similar approach. GNOME probes connectivity-check.gnome.org (or a configurable URL) and expects a 204. KDE has its own check. But in practice, Linux users are technical enough to open a browser and hit any HTTP URL if the portal doesn't trigger automatically.

NetworkManager on most distributions supports captive portal detection. If configured correctly, it shows a notification prompting the user to log in. The default configuration just works with standard HTTP redirects.

Like Windows, Linux was handled during initial development and didn't need ongoing attention. The browser-based flow (open browser → get redirected → accept → connect) worked reliably across Ubuntu, Fedora, Arch, and Debian.

The Redirect Problem

This is the one issue that cut across all platforms and took the longest to debug.

The flow that caused it:

  1. User connects to WiFi

  2. OS detects captive portal, opens a restricted browser (CNA, Android WebView, etc.)

  3. User accepts terms on the portal page

  4. Portal redirects user to their intended destination (google.com, twitter.com, whatever was in the dst parameter)

  5. The destination page opens inside the captive portal's browser context not in the user's real browser

  6. User thinks "everything is opening in this strange window" and doesn't know how to escape

On iOS, the CNA would show google.com rendered inside its stripped-down browser. The user could navigate but had no address bar, no tabs, no way to understand they were still in the captive portal assistant. On Android, the same thing happened in the WebView that showed the portal page.

The fix: don't redirect to the user's destination. Instead, redirect to a page that says "You're connected!" and instructs them to close the captive portal browser and open their normal browser.

// Instead of: app.post('/auth', async (req, res) => { // ... res.redirect(req.body.dst || 'http://google.com'); }); // Do: app.post('/auth', async (req, res) => { // ... res.redirect('/connected'); }); // Connected page app.get('/connected', (req, res) => { res.send(` <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta http-equiv="refresh" content="5;url=http://captive.apple.com"> <title>Connected</title> <style> body { font-family: -apple-system, sans-serif; text-align: center; padding: 2em; } h1 { color: #34c759; } </style> </head> <body> <h1>You're Connected!</h1> <p>Close this window and open your browser again.</p> <p>You'll be redirected shortly.</p> </body> </html> `); }); 

The meta refresh to captive.apple.com at the end is a nudge to trigger iOS's re-probe. The OS sees captive.apple.com returning the success page and marks the network as connected.

This seems counterintuitive why not send users directly to their destination? but it's the most reliable approach. The captive portal browser context is a temporary environment. Don't try to make it a real browser. Just get the user out of it.

Reliability Patterns

1. MAC + IP + Cookie Triple Binding

MAC randomization (iOS 14+, Android 10+) broke MAC-only session tracking. A device reconnecting gets a new MAC, appearing as a new client.

Bind all three identifiers and accept that some sessions will be ephemeral:

function getSessionKey(req: express.Request): string { const mac = req.headers['x-client-mac'] || ''; const ip = req.ip; const token = req.cookies?.session || ''; return `${mac}:${ip}:${token}`; } 

When any one component changes, treat it as a new session. The cookie is the most stable identifier it persists across reconnects within the same browser.

2. Session Expiry and Graceful Renewal

Sessions must expire. Network resources aren't infinite. But cutting users off mid-stream creates a terrible experience.

// Check expiry on every request function getSession(req: express.Request): Session | null { const token = req.cookies?.session; if (!token) return null; const session = sessions.get(token); if (!session) return null; if (Date.now() > session.expiresAt) { sessions.delete(token); removeIptablesBypass(session.ip, session.mac); return null; } // Renew if within 10% of expiry const remaining = session.expiresAt - Date.now(); const totalDuration = session.expiresAt - session.createdAt; if (remaining < totalDuration * 0.1) { session.expiresAt = Date.now() + totalDuration; } return session; } // Cleanup expired sessions setInterval(async () => { const now = Date.now(); for (const [token, session] of sessions) { if (now > session.expiresAt) { await removeIptablesBypass(session.ip, session.mac); sessions.delete(token); } } }, 60_000); 

3. Handle the HTTPS Problem

Modern internet is mostly HTTPS. Your captive portal can't redirect HTTPS traffic without breaking the TLS handshake. Two approaches work in production:

Option A: iptables REDIRECT for port 443 This captures the TCP connection before TLS starts. Nginx can then terminate with a self-signed cert.

iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 443 -j REDIRECT --to-port 8443 
server { listen 8443 ssl default_server; ssl_certificate /etc/nginx/ssl/captive.crt; ssl_certificate_key /etc/nginx/ssl/captive.key; location / { return 302 http://captive.portal.local/login; } } 

Users see a certificate warning. Most will click through.

Option B: DNS-only, no HTTPS interception Let HTTPS connections fail silently. The OS detects the portal via HTTP probes and shows the notification. After auth, HTTPS works normally.

# Only redirect HTTP iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 80 -j REDIRECT --to-port 8080 

In practice, Option B was more reliable across devices. The certificate warning on Option A confused non-technical users, and some devices refused to proceed past the warning entirely.

4. DNS Cache Management

Aggressive DNS caching causes a problem: even after authentication, the device remembers that google.com = 192.168.1.1.

Mitigations:

  • Set low TTLs on your hijacked DNS responses

  • After authentication, redirect the user to a page that triggers DNS refresh

# /etc/dnsmasq.conf max-ttl=60 

5. Minimum Viable Login Page

Your login page ...