pfSense's built-in captive portal supports simple voucher and RADIUS auth — but modern public WiFi needs SMS OTP login, email confirmation and social sign-in. Here's how to plug real third-party APIs into your existing pfSense deployment.
User enters their phone number → pfSense generates a 6-digit OTP → SMS gateway delivers it → user enters OTP → access granted. Standard flow across India's cafes, hotels and co-working spaces.
Four solid options for the Indian market, in order of price-to-reliability:
If your venue is in India, you must register as a Principal Entity on the DLT portal (Jio/Airtel/Vodafone/BSNL each have one) and get your OTP template approved. This takes 2-3 business days. Your SMS gateway (MSG91 / Gupshup) will walk you through registration.
pfSense's built-in captive portal doesn't natively do SMS — you extend it with freeRADIUS and a small PHP script. Here's the full setup.
You need freeRADIUS for session management and PHP-CLI for the OTP script.
System › Package Manager › Available › freeradius3Paste the PHP below into /usr/local/captiveportal/send_otp.php on your pfSense box (via Diagnostics › Command Prompt or SSH). Replace the MSG91 placeholders with your own.
Create /usr/local/captiveportal/verify_otp.php which checks the submitted code against the stored one and calls the captive portal login.
Use the hotel/cafe template from our templates page and swap the voucher field for a phone-number input that POSTs to send_otp.php, plus a second screen with the OTP input that POSTs to verify_otp.php.
Authentication happens in your PHP scripts, not pfSense's built-in flow.
Services › Captive Portal › (zone) › Authentication: NoneAdd firewall rule: LAN → api.msg91.com (or your gateway's CIDR) : TCP/443 Allow. So pfSense can reach the SMS API.
<?php # Send a 6-digit OTP to the user's phone via MSG91 # Called when user submits their mobile number from the portal page $phone = preg_replace('/[^0-9]/', '', $_POST['phone']); if (strlen($phone) < 10) { die('Invalid mobile'); } $otp = rand(100000, 999999); $apiKey = 'YOUR_MSG91_AUTHKEY'; # from MSG91 dashboard $templId = 'YOUR_DLT_TEMPLATE_ID'; # DLT-approved template $senderId = 'KHOJIN'; # DLT-approved 6-char sender $zone = $_POST['zone'] ?? 'cpzone'; $redirurl = $_POST['redirurl'] ?? '/'; # Store OTP in /tmp with 5-min TTL file_put_contents("/tmp/otp_{$phone}", $otp); touch("/tmp/otp_{$phone}", time() + 300); # Send via MSG91 Flow API $payload = [ 'template_id' => $templId, 'short_url' => '0', 'recipients' => [[ 'mobiles' => '91' . $phone, 'OTP' => (string)$otp, ]] ]; $ch = curl_init('https://api.msg91.com/api/v5/flow/'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "authkey: {$apiKey}", ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, ]); $resp = curl_exec($ch); curl_close($ch); # Show the OTP-entry page (include verify form + pass session vars) include 'otp_entry_page.php';
<?php # Verify OTP + log user in via pfSense captive portal require_once('/etc/inc/captiveportal.inc'); $phone = preg_replace('/[^0-9]/', '', $_POST['phone']); $entered = preg_replace('/[^0-9]/', '', $_POST['otp']); $stored = @file_get_contents("/tmp/otp_{$phone}"); $expired = filemtime("/tmp/otp_{$phone}") < time(); if (!$stored || $expired || $entered !== trim($stored)) { die('Invalid or expired OTP'); } # Delete used OTP (single-use) @unlink("/tmp/otp_{$phone}"); # Authenticate via pfSense's captive portal $cpzone = $_POST['zone'] ?? 'cpzone'; $clientIP = $_SERVER['REMOTE_ADDR']; $clientMAC = arp_get_mac_by_ip($clientIP); $sessId = portal_allow($clientIP, $clientMAC, "+91{$phone}", null, null, null, null); if ($sessId) { header('Location: ' . ($_POST['redirurl'] ?? 'https://www.google.com')); exit; } die('Login failed. Contact staff.');
Two separate use cases: admin alerts (pfSense sends you notifications when something interesting happens) and user-facing email (welcome message, receipt, confirmation link).
pfSense has native SMTP support for operational notifications: firewall alerts, CARP failovers, new IP from ISP, system updates, etc. Configure once and it sends emails for every notification event.
For most teams, the fastest path is Gmail with an App Password or a dedicated transactional provider. See the cheatsheet below.
Click Test SMTP Settings. If it fails, check firewall rules — pfSense itself must be allowed to reach the SMTP host on port 465/587.
E-Mail server address : smtp.sendgrid.net SMTP Port of E-Mail server: 587 Connection timeout : 20 seconds Secure SMTP Connection : STARTTLS From e-mail address : [email protected] Notification E-Mail address: [email protected] Authentication Mechanism : PLAIN Username : apikey # literal string for SendGrid Password : SG.xxxxxxxxxxxxxxxxxxxx # your SendGrid API key
For portal users (e.g. send a welcome email after WiFi connect, or require email confirmation before access), use a PHP hook in the captive portal scripts.
<?php # Sends a welcome email via SendGrid when a user connects $to = $_POST['email']; $name = $_POST['name'] ?? 'Guest'; $venue = 'Cafe Khoji'; $apiKey = 'SG.xxxxxxxxxxxxxxxxxxxx'; $payload = [ 'personalizations' => [['to' => [['email' => $to, 'name' => $name]]]], 'from' => ['email' => '[email protected]', 'name' => $venue], 'subject' => "Welcome to {$venue} WiFi, {$name}", 'content' => [[ 'type' => 'text/html', 'value' => "<h2>You're connected.</h2><p>Enjoy 500 Mbps free WiFi. Show this email to redeem a free beverage.</p>", ]], ]; $ch = curl_init('https://api.sendgrid.com/v3/mail/send'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "Authorization: Bearer {$apiKey}", ], CURLOPT_RETURNTRANSFER => true, ]); curl_exec($ch); curl_close($ch);
For corporate visitor portals where employees want to "Sign in with Google" or Microsoft 365, and for cafes/B2C venues where "Sign in with Facebook" reduces drop-off.
pfSense's captive portal doesn't natively talk OAuth — but there are two clean ways to make it work:
Configure pfSense to redirect unauthenticated users to the external portal URL. The external service handles OAuth with Google/Microsoft/FB and sends a RADIUS accept/reject back to pfSense. Zero custom code — but monthly SaaS cost.
Services › Captive Portal › (zone) › Authentication: RADIUS → external providerRun oauth2-proxy on pfSense (or a sidecar VM), configure it as an OAuth client in your Google/MS console, and use the X-Forwarded-Email header from oauth2-proxy to feed freeRADIUS. More work but zero ongoing cost.
Your portal page opens a popup to Google's OAuth, captures the returned email, POSTs it to your verify_oauth.php which calls portal_allow() after checking the Google token signature. Lightest-weight if you're comfortable with PHP.
<?php # Google OAuth callback — verify the JWT id_token, then grant access require_once('/etc/inc/captiveportal.inc'); $idToken = $_POST['id_token']; # from Google Sign-In JS SDK $allowedDomain = 'yourcompany.com'; # restrict to corp email # Verify with Google's tokeninfo endpoint $resp = file_get_contents("https://oauth2.googleapis.com/tokeninfo?id_token={$idToken}"); $info = json_decode($resp, true); if (!isset($info['email']) || !$info['email_verified'] || $info['aud'] !== 'YOUR_GOOGLE_CLIENT_ID' || $info['hd'] !== $allowedDomain) { http_response_code(403); die('Domain not allowed'); } # Grant portal access $clientIP = $_SERVER['REMOTE_ADDR']; $clientMAC = arp_get_mac_by_ip($clientIP); portal_allow($clientIP, $clientMAC, $info['email'], null, null, null, null); header('Location: ' . $_POST['redirurl']);
DLT registration + SMS gateway setup + portal HTML + freeRADIUS + testing. Delivered end-to-end, ready for your first guest login.