OPNsense OpenVPN Server with Username/Password + Yubikey OTP Authentication

Here is a quick guide and an authentication script to support using a Yubikey OTP for two-factor authentication on an OPNsense OpenVPN server. This approach works alongside username/password authentication, including when using different OPNsense authentication non-local backends such as LDAP and Radius.

We put this authentication script together as a proof-of-concept after having multiple folks ask us for help. For a standard OpenVPN setup, such as on Ubuntu, you can follow our guide here instead.

It has the advantage that it doesn’t replace or bypass OPNsense’s authentication process, so authentication you’ve configured in OPNsense will still apply. However please note that as it’s a proof-of-concept, we don’t recommend using it as is for large deployments as-is (mostly due to it using a simple local database file), you should review it to be sure it suits your security model, and future OPNsense releases may break it.

The following steps assume you already have an OpenVPN server setup in OPNsense with username/password authentication. These steps have been tested on the latest version of OPNsense at the time of writing (25.7.2), and assume you have SSH/SFTP and web admin access to the OPNsense server.

  1. Log into the admin web interface for OpenVPN, go to VPN → OpenVPN → Servers, and click the Edit button next to your existing server.
  2. In your web browser’s address bar take note of the ID of the server. For example, the address bar should have something like https://192.168.0.1/vpn_openvpn_server.php?act=edit&id=0. In this example we can see id=0, so the ID is “0”. We’ll need this number later.
  3. Scroll down to Advanced and add the following lines. These set a custom authentication script to use, set a security level that allows the script to run, and also ensures that session tokens are used (to avoid needing to prompt the user for their Yubikey OTP every time a renegotiation takes place):
    auth-user-pass-verify /scripts/opnsense_openvpn_auth.py via-env
    script-security 3
    auth-gen-token 86400
    
  4. Save the changes.
  5. If you don’t already have a Yubico API Client ID and Secret Key, visit Yubico API key signup and create one. You’ll need these details later.
  6. SSH into the OPNsense server, and select the Shell option.
  7. Enter the command mkdir /scripts to create a directory.
  8. On your computer, create a plain text file called opnsense_openvpn_auth.py and enter the following source code:
    #!/usr/local/bin/python3
    
    import sys, os
    from base64 import b64decode
    
    # Replace the following details:
    serverID = "1"
    yubicoClientId = ""
    yubicoSecretKey = ""
    
    # Read in the username and password from the environment
    username = os.environ['username']
    password = os.environ['password']
    
    # Extract the token from the encoded password
    if password.startswith('SCRV1:'):
    	# Extract the actual password and token
    	passwordSplit = password.split(':')
    	password = b64decode(passwordSplit[1]).decode("utf-8")
    	token = b64decode(passwordSplit[2]).decode("utf-8")
    else:
    	# Invalid data
    	sys.exit(1)
    
    # Check the username and password using pfSense's authentication script. This
    # supports PAM, LDAP, Radius, etc.
    os.environ['password'] = password
    import subprocess
    try:
    	subprocess.check_call(["/usr/local/opnsense/scripts/openvpn/ovpn_event.py", serverID])
    	loginValid = True
    except:
    	loginValid = False
    if not loginValid:
    	sys.exit(1)
    
    # The first 12 characters of the token is the unique public ID
    tokenId = token[:12]
    
    # Only accept the token assigned to the user. If this is the first time
    # a token has been used for a user, assign it to the user.
    import pickle
    dbPath = '/scripts/token_index.db'
    if os.path.exists(dbPath):
    	try:
    		file = open(dbPath,'rb')
    		tokenDb = pickle.load(file)
    		file.close()
    	except:
    		tokenDb = {}
    else:
    	tokenDb = {}
    
    updateDb = False
    if username in tokenDb.keys() and tokenDb[username] != tokenId:
    	# The token being used does not match the token for the user
    	sys.exit(1)
    else:
    	tokenDb[username] = tokenId
    	updateDb = True
    
    # Check that the token is valid using the Yubico Cloud service
    from yubico_client import Yubico
    yubico = Yubico(yubicoClientId, yubicoSecretKey)
    
    try:
    	tokenValid = yubico.verify(token)
    except:
    	tokenValid = False
    
    if not tokenValid:
    	sys.exit(1)
    
    # Save the token database if necessary
    if updateDb:
    	file = open(dbPath,'wb')
    	pickle.dump(tokenDb, file, -1)
    	file.close()
    
    sys.exit(0)
    
  9. Edit the script and set the serverID, yubicoClientId, and yubicoSecretKey variables to those we noted earlier.
  10. Upload the opnsense_openvpn_auth.py file to the /scripts directory we created on the OPNsense server using SFTP (such as using the command line or using a SFTP client like Transmit or Cyberduck).
  11. Download the Python yubico-client source distribution from yubico-client · PyPI and extract it on your computer.
  12. Inside the extracted folder there should be a yubico_client directory. Upload this directory into the /scripts directory using SFTP.
  13. In Viscosity, edit your VPN connection for the OPNsense server (or import a new one), and under the Advanced tab add the following advanced command on a new line and then click Save. Feel free to customise the message that appears.
    static-challenge "Enter code:" 0
  14. Try connecting to your OpenVPN server.

When connecting users will first be prompted for their username/password as normal, and then be prompted for their Yubikey OTP (you can customise the message that appears).

The first time a user successfully connects and authenticates, the Yubikey used will become associated with their username. If they attempt to use a different Yubikey then authentication will fail.

Cheers,
James