← Start of tutorial

WIUNIX ACADEMY

OpenVPN MFA with client certificates and Google Authenticator

Page 2 of 5. Review the earlier steps and prerequisites before continuing.

YOUR SYSTEM. YOUR STEPS.

Where are you working?

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

Debian 12Reviewed 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
05

LET’S BUILD

Open a new dedicated PAM service

The next file is OTP-only authentication plus OS-account expiry validation. Do not include common-auth or pam_unix in the auth stack: that would ask for a Linux password, unlike this recipe. account pam_unix checks account expiry; a locked password and nologin shell alone are not VPN revocation. Expire the account or revoke its certificate when access must end.

bash

Dedicated pilot VM; private terminal for enrollment

set -eu
sudo test ! -e '/etc/pam.d/academy-mfa'
sudo test ! -L '/etc/pam.d/academy-mfa'
sudo sh -c 'umask 022; set -C; : > "/etc/pam.d/academy-mfa"'
sudo nano '/etc/pam.d/academy-mfa'
How do I know it worked?

A new root-owned PAM file is ready; existing system login/SSH PAM files are untouched.

06

LET’S BUILD

Enforce TOTP without a password fallback

noskewadj disables automatic drift learning; fix clock problems instead of teaching the server a wider offset. No nullok, grace period, no_strict_owner, relaxed permissions or allow_readonly is enabled. The module must be able to update its private seed state to record replay/rate-limit data; a read-only or full filesystem must fail closed.

This verifies possession of the seed in addition to the certificate private key; it is not a Google-account login. The separate account line is required because auth-pam also invokes PAM account management.

text · /etc/pam.d/academy-mfa

Dedicated pilot VM; private terminal for enrollment

auth required pam_google_authenticator.so noskewadj
account required pam_unix.so
How do I know it worked?

Only a valid personal code and a non-expired OS account pass this PAM service.

Understand the single client password prompt

The server plugin explicitly maps the PAM prompt beginning Verification to OpenVPN PASSWORD. With this OTP-only stack, type just the six-digit current TOTP, including a leading zero, into the password box. A recovery code is an alternative single-use code handled by the module, never a reusable password. No static-challenge or stored auth file is needed for normal use.

A password+OTP deployment is different: google-authenticator forward_pass parses the combined password+code and passes the password to a subsequent pam_unix use_first_pass module. Simply adding pam_unix without prompt/stack changes is not equivalent. Other clients can use static-challenge and the plugin OTP mapping, but that is a different tested contract. Do not combine these patterns casually. This guide does not enable auth-gen-token: reconnects/renegotiation need fresh prompting, and non-interactive clients may disconnect. Test your actual GUI client before rollout.

07

LET’S BUILD

Prepare the fail-closed certificate-name hook

auth-pam validates the submitted account, but does not by itself bind that account to a specific certificate. The next hook reads only the first line of OpenVPN’s temporary credential file and compares it exactly, case-sensitively, to the original certificate common_name. The password is never read by this hook, passed in argv, printed or logged. A missing file/name fails.

Only ASCII identifiers of1–32characters are accepted, matching the public username control. Issue unique client certificate CNs from that same namespace. No duplicate-cn, shared certificate, username-as-common-name or optional client certificate is allowed.

bash

Dedicated pilot VM; private terminal for enrollment

set -eu
sudo sh -c 'umask 022; set -C; : > "/srv/academy-openvpn-mfa/bind-cn.py"'
sudo nano '/srv/academy-openvpn-mfa/bind-cn.py'
How do I know it worked?

The new hook file is ready without overwriting an existing file.

08

LET’S BUILD

Compare the username with the original CN

OpenVPN runs every configured authentication verifier; failure of this hook rejects the connection even if PAM accepted the code. The hook returns only an exit status. Root owns the hook; the reduced daemon may read/execute but cannot modify it. File input avoids exporting the OTP to a script environment.

python · /srv/academy-openvpn-mfa/bind-cn.py

Dedicated pilot VM; private terminal for enrollment

#!/usr/bin/python3
import os
import re
import sys

def main():
    if len(sys.argv) != 2:
        return 1
    try:
        with open(sys.argv[1], 'rb') as stream:
            raw = stream.readline(128)
        name = raw.rstrip(b'\r\n').decode('ascii')
    except (OSError, UnicodeError):
        return 1
    if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_-]{0,31}', name):
        return 1
    return 0 if name == os.environ.get('common_name', '') else 1

raise SystemExit(main())
How do I know it worked?

The hook rejects mismatched or malformed names and emits no credential data.

09

LET’S BUILD

Import an existing reviewed PKI baseline

Use your existing controlled CA process to provision ca.crt, server.crt, server.key, crl.pem and tls-crypt.key into the protected pki directory. Do not copy the CA signing key to the VPN VM. The server leaf must have serverAuth EKU and CN vpn-server; issue the pilot client with clientAuth EKU and CN exactly academyvpn. The server key below must be usable unattended under your approved protected-key policy. The client private key remains private on its endpoint.

Transfer files through an authenticated protected channel, not a web form. Keep the private key/tls-crypt material0600 and root-owned; public certificates and CRL0644. CRL parent traversal must allow the dropped daemon to reread it, while private keys remain inaccessible. This panel checks existing files; it does not create certificates or weaken validation. Keep CA renewal/revocation ownership and expiry monitoring explicit.

bash

Dedicated pilot VM; private terminal for enrollment

set -eu
sudo chmod 0755 '/srv/academy-openvpn-mfa/pki'
sudo chown root:root '/srv/academy-openvpn-mfa/pki/'*
sudo chmod 0600 '/srv/academy-openvpn-mfa/pki/server.key' '/srv/academy-openvpn-mfa/pki/tls-crypt.key'
sudo chmod 0644 '/srv/academy-openvpn-mfa/pki/ca.crt' '/srv/academy-openvpn-mfa/pki/server.crt' '/srv/academy-openvpn-mfa/pki/crl.pem'
sudo openssl verify -purpose sslserver -CAfile '/srv/academy-openvpn-mfa/pki/ca.crt' '/srv/academy-openvpn-mfa/pki/server.crt'
sudo openssl x509 -in '/srv/academy-openvpn-mfa/pki/server.crt' -noout -subject -ext extendedKeyUsage -checkend 86400
sudo openssl crl -in '/srv/academy-openvpn-mfa/pki/crl.pem' -noout -issuer -lastupdate -nextupdate
sudo openssl crl -in '/srv/academy-openvpn-mfa/pki/crl.pem' -verify -CAfile '/srv/academy-openvpn-mfa/pki/ca.crt' -noout
sudo chmod 0755 '/srv/academy-openvpn-mfa/bind-cn.py'
sudo python3 -m py_compile '/srv/academy-openvpn-mfa/bind-cn.py'
How do I know it worked?

The server chain/purpose and signed CRL validate; CN/EKU/expiry and private-file permissions are reviewed.

Explore all tutorials ↗