TOTP-Based Guest Access for OPNsense Captive Portal (Replacing Vouchers)
Problem:
OPNsense’s built-in captive portal uses a voucher system for guest access. You generate vouchers, print them, hand them out. It works, but it’s cumbersome — you’re managing paper codes, rotating batches, and dealing with guests who lose their slip. For a small office or home network where you just want a quick way to let guests online, it’s overkill.
Solution:
Replace the voucher system with a shared TOTP (Time-based One-Time Password) authenticator. You add one secret to your phone’s authenticator app. When a guest needs access, you read them the current 6-digit code. They enter it on a minimal dark portal page and get 1 week of network access. The code rotates every 30 seconds, so it can’t be reused after it expires.
The project: github.com/CallMeGwei/captive-portal-totp
How It Works:
A custom auth connector (SharedTOTP.php) plugs into OPNsense’s standard authentication framework. It extends OPNsense\Auth\Base, implements IAuthConnector, and reuses the existing TOTP trait for RFC 6238 code validation. It reads a shared secret from /usr/local/etc/captiveportal_totp.conf, validates the 6-digit code, and grants a 1-week session on success. The username field is ignored entirely — only the code matters.
The core authentication logic:
protected function _authenticate($username, $password)
{
if (!preg_match('/^\d{6}$/', $password ?? '')) {
return false;
}
$base32Secret = trim(file_get_contents($this->configFile));
$binarySecret = \Base32\Base32::decode($base32Secret);
if ($this->authTOTP($binarySecret, $password)) {
$this->lastAuthProperties['session_timeout'] = 604800;
return true;
}
return false;
}
The portal page is a single input field — dark background, no branding, no clutter. It filters to digits only and auto-submits when 6 digits are entered:
$code.on("input", function () {
this.value = this.value.replace(/[^0-9]/g, "").slice(0, 6);
if (this.value.length === 6) doLogin();
});
The portal template is embedded as a base64-encoded zip in config.xml, so it survives OPNsense upgrades and template reloads — no files to maintain outside of the config.
Install:
SSH into your OPNsense box and run:
curl -sL https://raw.githubusercontent.com/CallMeGwei/captive-portal-totp/main/get.py | python3
This downloads the project files to a temp directory and runs the installer, which:
1. Copies the auth connector into OPNsense’s auth library.
2. Generates a TOTP secret and prints the otpauth:// URI (add this to your authenticator app).
3. Registers the auth server in config.xml and points the captive portal zone to it.
4. Embeds the portal template and restarts the captive portal.
To remove it and restore the default voucher system:
curl -sL https://raw.githubusercontent.com/CallMeGwei/captive-portal-totp/main/get.py | python3 - --remove
Developed and tested on OPNsense 26.1 (FreeBSD 14.3). Should work on any version with the same auth framework.
No more paper vouchers. Carry on.