Illustrative security training studio with seated operator, personal phone and certificate/TOTP VPN diagram.

WIUNIX ACADEMY

OpenVPN MFA with client certificates and Google Authenticator

Build an isolated OpenVPN pilot with personal TOTP, PAM, exact certificate CN binding, replay controls and private recovery while retaining certificate validation.

Intermediate70 min readUpdated
Make this guide yours

YOUR SYSTEM. YOUR STEPS.

Where are you working?

Choose your OS and version. The prerequisites, commands and troubleshooting follow your environment.

Ubuntu 26.10Reviewed against official documentation ·

USE YOUR OWN VALUES

Configure this tutorial

Set the example values for your environment; related explanations and copy panels update together. Never enter passwords, private keys or tokens. Values stay in this tab and are not sent to the server or share links. Check actual DNS, certificates and network policy before running commands.

Different IP addresses, domains or ports? Open the settings below, change the values and select “Apply to tutorial”, then copy the updated commands.

Set values for my environment7 editable values

THE OUTCOME

What will you have at the end?

Require a verified client certificate, matching CN/username and personal TOTP.

01Certificatevpn.example.net UDP 11940 → 192.0.2.40 → verified client certificate
02PAM + CNacademyvpn → academy-mfa → personal TOTP AND original CN equality
03TUNtun-mfa 10.93.0.1/24 → reviewed INPUT policy; no forwarding
Supported operating systems and versions

OS paths are documented; actual isolated evidence and pilot acceptance limits are stated at the end.

Before you begin

  • Fresh sudo-capable VM, independent console, owned DNS/firewall and unused10.93.0.0/24 pilot pool.
  • Controlled CA, individual client certificate, current CRL, protected transfer and personal TOTP app.
  • Open settings, edit, Apply, then copy. Public values stay only in this tab/language/article/environment; never enter seeds, codes, passwords or keys.

Require a certificate AND a personal TOTP

Build a separate pilot OpenVPN instance on a fresh VM. A client must possess a valid certificate/private key, submit its exact certificate CN as username, and enter a personal TOTP in the password box. The PAM stack below validates the OTP alone; it does not ask for a Linux password. The certificate key and authenticator must be held separately: storing both on one compromised laptop weakens factor independence.

This intermediate exercise takes about 70 minutes plus PKI/enrollment work. It is not a replacement for an organization’s access design. TOTP is phishable; MFA neither authorizes tunnel destinations nor protects a stolen connected session.

MFA terminology boundary: a software certificate key and a TOTP without independently enforced activation can both be possession-based. This pilot alone does not demonstrate NIST AAL2 or two different factor categories. If policy requires that assurance, enforce and assess a knowledge/biometric activation factor for the key or authenticator rather than inferring it from two prompts.

Keep certificate identity through both checks

vpn.example.net reaches 192.0.2.40 on UDP 11940, directly or through an explicitly reviewed NAT rule. Certificate validation happens before PAM. The packaged auth-pam plugin checks the named user’s seed; a second authentication hook compares that username with the original certificate CN. Both must succeed. username-as-common-name is deliberately absent: it changes names seen by later hooks and CCD lookup, and is not a CN/username equality check.

The fixed pilot address pool is 10.93.0.0/24, server 10.93.0.1. It must not overlap any client, VM or routed network. No default route, DNS push, forwarding, NAT or client-to-client access is configured. The initial test reaches only an approved service on the VPN server.

01

LET’S BUILD

Check a fresh VM, time and collisions

Use an independent console and a fresh sudo-capable VM, never paste these creation steps over an existing VPN. Confirm 192.0.2.40 is assigned locally and UDP 11940 is unused. The hostname must resolve to the public ingress, which may differ from this local address. Stop for an existing account academyvpn, interface tun-mfa, directory /srv/academy-openvpn-mfa, PAM service or unit academy-mfa. Review parent directories for symlinks before proceeding.

The time synchronization result must be yes and the phone must use automatic accurate time. Keep the existing NTP provider; repair it through its owner rather than installing a second daemon. Do not change host time to test an OTP. Apply only the pilot UDP rule and restricted tunnel INPUT access through the firewall owner; never flush or replace a firewall.

bash

Dedicated pilot VM; private terminal for enrollment

set -eu
cat /etc/os-release
test "$(ps -p 1 -o comm=)" = systemd
ip -br address
ip route show table all
sudo ss -lunp
getent passwd 'academyvpn' && exit 1 || test "$?" = 2
for path in '/srv/academy-openvpn-mfa' '/etc/pam.d/academy-mfa' '/etc/systemd/system/academy-mfa.service'; do
  test ! -e "$path" && test ! -L "$path"
done
ip link show 'tun-mfa' >/dev/null 2>&1 && exit 1 || test "$?" = 1
timedatectl status
timedatectl show -p NTPSynchronized --value
How do I know it worked?

No collision; the local ingress, non-overlapping pool, independent console and synchronized time are confirmed.

02

LET’S BUILD

Install signed packages for the selected release

Use distribution packages and their security updates, not a one-line installer. Ubuntu 26.10 is the reviewed current stable default, supported through July 2027; 26.04/24.04 LTS are longer-lived alternatives. Debian 13 is stable; Debian 12 is in LTS with package/architecture coverage to check. The recipe targets OpenVPN 2.6-compatible community configuration, not Access Server or every GUI client. Record installed candidates; do not pin a stale upstream version.

Only documented OS paths are offered. The actual runtime fixture used Ubuntu 24.04 packages; this does not establish a booted installation on every listed release.

bash

Dedicated pilot VM; private terminal for enrollment

set -eu
. /etc/os-release
test "$VERSION_CODENAME" = 'stonking'
sudo apt-get update
apt-cache policy openvpn libpam-google-authenticator python3
sudo apt-get install --no-install-recommends openvpn libpam-google-authenticator python3 openssl ca-certificates nano
openvpn --version | head -n 2
dpkg-query -W openvpn libpam-google-authenticator python3
How do I know it worked?

The release guard passes and actual signed package versions are recorded.

03

LET’S BUILD

Create a dedicated account and discover the plugin

This guarded creation refuses existing paths/accounts and symlink parents. Review before running; a partial failure leaves evidence to inspect, not an invitation to rerun blindly. academyvpn is a new locked, non-login OS identity; it receives no shell or sudo permission. The auth-pam library is discovered from the package list, resolved to one real root-owned non-writable target and linked locally; multiarch paths are not guessed.

The temporary auth directory is accessible only to the reduced OpenVPN UID. The packaged PAM helper remains privileged through its split-process model and can access the personal seed without making it readable by nobody. Do not add chroot here or replace the packaged plugin with an unrelated library.

bash

Dedicated pilot VM; private terminal for enrollment

sudo python3 - <<'PY'
import os, pathlib, pwd, subprocess
root=pathlib.Path('/srv/academy-openvpn-mfa')
assert root.is_absolute()
for p in [root, *root.parents]:
    assert not p.is_symlink(), 'Symlink path refused'
assert not root.exists(), 'Existing root refused'
for p in ['/etc/pam.d/academy-mfa','/etc/systemd/system/academy-mfa.service']:
    assert not os.path.lexists(p), 'Existing service refused'
try:
    pwd.getpwnam('academyvpn')
except KeyError:
    pass
else:
    raise SystemExit('Existing user refused')
root.mkdir(mode=0o755)
os.chmod(root,0o755)
for name,mode in [('pki',0o700),('users',0o755),('auth-tmp',0o700)]:
    (root/name).mkdir(mode=mode)
    os.chmod(root/name,mode)
subprocess.run(['useradd','--system','--user-group','--home-dir',str(root/'users/academyvpn'),'--create-home','--shell','/usr/sbin/nologin','academyvpn'],check=True)
u=pwd.getpwnam('academyvpn')
os.chmod(root/'users/academyvpn',0o700)
os.chown(root/'users/academyvpn',u.pw_uid,u.pw_gid)
n=pwd.getpwnam('nobody')
os.chown(root/'auth-tmp',n.pw_uid,n.pw_gid)
paths=[p for p in subprocess.check_output(['dpkg','-L','openvpn'],text=True).splitlines() if p.endswith('/openvpn-plugin-auth-pam.so') and pathlib.Path(p).is_file()]
targets={pathlib.Path(p).resolve() for p in paths}
assert len(targets)==1, 'Ambiguous/missing package plugin'
plugin=next(iter(targets))
assert plugin.stat().st_uid==0 and not plugin.stat().st_mode & 0o022
(root/'auth-pam.so').symlink_to(plugin)
print('Dedicated account/tree created; packaged plugin:',plugin)
PY
How do I know it worked?

A new locked identity, private home,0700 auth temporary directory and validated package-plugin link exist.

04

LET’S BUILD

Enroll privately, once per person

Run only in a private unrecorded terminal attended by the actual user. The command is interactive and intentionally reveals a QR/seed and recovery codes: never run it through this page, screen sharing, saved command output or a logging automation. Check that the home has no existing seed, then enroll Google Authenticator or another compatible TOTP app. Choose time-based tokens, disallow reuse, a small three-code window and three attempts per30seconds. The flags set those choices; confirm saving the file after comparing the app.

Do not follow an enrollment URL that sends the seed to an external QR service. Keep emergency codes offline and private, separate from the certificate and phone. Never put a seed, QR, OTP or recovery code in reader configuration, source JSON, screenshots or support logs. One identity gets one unique seed; administrators must not distribute a shared seed.

bash

Dedicated pilot VM; private terminal for enrollment

set -eu
sudo test ! -e '/srv/academy-openvpn-mfa/users/academyvpn/.google_authenticator'
sudo test ! -L '/srv/academy-openvpn-mfa/users/academyvpn/.google_authenticator'
sudo -H -u 'academyvpn' google-authenticator -t -d -w 3 -r 3 -R 30
sudo chmod 0600 '/srv/academy-openvpn-mfa/users/academyvpn/.google_authenticator'
sudo stat -c '%U %a %n' '/srv/academy-openvpn-mfa/users/academyvpn/.google_authenticator'
How do I know it worked?

Private enrollment completes with a user-owned0600 seed; no seed/code is copied into public evidence.

Explore all tutorials ↗