Home/Captive Portal Authentication
Integration Guide

Mobile OTP + Email + OAuth for pfSense Captive Portal

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.

Method 1

Mobile OTP Login (SMS)

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.

📱 User Captive Portal pfSense + freeRADIUS SMS Gateway API 📨 OTP delivered

Pick an SMS gateway

Four solid options for the Indian market, in order of price-to-reliability:

India
  • Per-SMS ₹0.18
  • Free trial 25 SMS
  • DLT ready
  • Delivery ~3s
  • API REST + PHP SDK
Get API key →
India
  • Per-SMS ₹0.15
  • Free trial 100 SMS
  • DLT ready
  • Delivery ~5s
  • API REST
Get API key →
Global
  • Per-SMS $0.0075
  • Free trial $15 credit
  • DLT ready via partner
  • Delivery ~2s
  • API REST + SDK
Get API key →
India · UK
  • Per-SMS ₹0.15
  • Free trial 25 SMS
  • DLT ready
  • Delivery ~4s
  • API REST
Get API key →
DLT / TRAI Compliance (India)

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.

Implementation on pfSense

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.

1

Install the required packages

You need freeRADIUS for session management and PHP-CLI for the OTP script.

System › Package Manager › Available › freeradius3
2

Create the OTP generator script

Paste 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.

3

Create the OTP verifier

Create /usr/local/captiveportal/verify_otp.php which checks the submitted code against the stored one and calls the captive portal login.

4

Update the portal HTML

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.

5

Set captive portal to "None" auth

Authentication happens in your PHP scripts, not pfSense's built-in flow.

Services › Captive Portal › (zone) › Authentication: None
6

Whitelist the SMS gateway IPs

Add firewall rule: LAN → api.msg91.com (or your gateway's CIDR) : TCP/443 Allow. So pfSense can reach the SMS API.

/usr/local/captiveportal/send_otp.php
<?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';
/usr/local/captiveportal/verify_otp.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.');
Method 2

Email Integration

Two separate use cases: admin alerts (pfSense sends you notifications when something interesting happens) and user-facing email (welcome message, receipt, confirmation link).

2a. Admin alerts — built-in SMTP

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.

1

Open Notifications config

System › Advanced › Notifications
2

Fill in SMTP details

For most teams, the fastest path is Gmail with an App Password or a dedicated transactional provider. See the cheatsheet below.

3

Test the connection

Click Test SMTP Settings. If it fails, check firewall rules — pfSense itself must be allowed to reach the SMTP host on port 465/587.

Easiest
  • Host smtp.gmail.com
  • Port 587
  • Auth PLAIN + App Password
  • Daily limit 2,000
  • Free
Transactional
  • Host smtp.sendgrid.net
  • Port 587
  • Auth API Key as password
  • Free tier 100/day
  • Paid from $15/mo
Developer
  • Host smtp.mailgun.org
  • Port 587
  • Auth PLAIN
  • Free tier 100/day
  • Paid from $15/mo
High-volume
  • Host email-smtp.<region>.amazonaws.com
  • Port 587
  • Auth IAM SMTP creds
  • First 62k free
  • After $0.10 / 1000
pfSense › Notifications — example (SendGrid)
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

2b. User-facing email — welcome message / confirmation

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.

/usr/local/captiveportal/on_login_email.php
<?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);
Method 3

OAuth with Google / Microsoft / Facebook

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.

B2B
  • OAuth 2.0
  • Domain restrict hosted-domain
  • Free for workspace
  • MFA inherited
  • Console console.cloud.google.com
Enterprise
  • OAuth 2.0
  • Tenant-restricted
  • Azure AD MFA
  • Console portal.azure.com
  • Conditional Access
Retail / Cafe
  • OAuth 2.0
  • Page check-in optional
  • Free for businesses
  • Low friction
  • Console developers.facebook.com
Hosted SaaS
  • All OAuth providers bundled
  • Analytics + CRM
  • From $30/AP/month
  • Zero pfSense coding
  • URL redirect mode

Integration approach

pfSense's captive portal doesn't natively talk OAuth — but there are two clean ways to make it work:

A

External captive portal (Cloud4Wi, Splash Access, Hotspotsystem)

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 provider
B

DIY with Apache + freeRADIUS + oauth2-proxy

Run 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.

C

Hybrid — OAuth popup + RADIUS backchannel

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.

/usr/local/captiveportal/verify_google.php (Option C excerpt)
<?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']);
Want this built for you?

Fully-integrated SMS OTP portal — built for you

DLT registration + SMS gateway setup + portal HTML + freeRADIUS + testing. Delivered end-to-end, ready for your first guest login.

✓ Copied