The plugin still gets recommended in threads that predate the current builds, and it fails at load time on a server that was compiled yesterday. That is not a configuration mistake you can fix with a path. The plugin and the script hook solve the same problem at different layers, and moving from one to the other changes what has to be installed, what runs with server privileges, and what a failed login actually tells you.

The myth: the plugin is the supported path

A plugin is a shared object loaded into the OpenVPN process, so it has to match the interface that process exposes. Builds change, packaging changes, and a library compiled for an older interface will simply refuse to load. Nothing in the server configuration can bridge that gap, which is why advice to "just install radiusplugin" ends with a server that will not start.

The script hook sits outside the process instead. OpenVPN executes a program, hands it the credentials, and reads back a verdict. That decoupling is what makes it survive upgrades, and it is also what makes it your responsibility in a way the plugin never was.

Neither approach is more secure by construction. One runs foreign code inside the daemon; the other runs your code beside it with the credentials in hand. The right question is which of those two risks you can actually operate.

What the plugin bound together

The plugin carried its own configuration file with the RADIUS host, shared secret, retry count and timeout, and the server configuration referenced it with a single line. Credential handling stayed inside the server process, which meant the password never crossed a process boundary the administrator had to secure.

The cost was coupling. Every OpenVPN upgrade risked an incompatible interface, and every distribution rebuilding the package risked producing a library that no longer matched. When that happens the failure is loud and immediate, which is the one mercy of the design.

If a plugin of that kind still loads on your build, it can be the smaller operational surface. Confirm the interface it was built against, keep it pinned to the build you tested, and treat any upgrade as a change that requires a full login test rather than a routine package update.

What the script hook actually trusts

The hook is configured with auth-user-pass-verify and a delivery mechanism: environment variables or a temporary file. With the environment form, OpenVPN requires script-security 3, the documented level that permits password exposure to scripts. That level is a deliberate weakening of the default, and it should be treated as a permission grant with a justification attached.

Everything the script touches inherits that trust. It must be root-owned, not writable by group or other, referenced by an absolute path, and free of any place where an untrusted value reaches a shell. Quote every variable, reject unexpected characters, and never build a command from the supplied username. A script that runs as root on every login attempt is worth reviewing once for exactly those four properties.

ls -l /usr/local/sbin/radius-auth.sh
stat -c '%U %a' /usr/local/sbin/radius-auth.sh

The file-delivery form avoids the environment inheritance problem and hands the credentials to the script through a temporary file with restricted permissions. If your client supports it, it is usually the better default, even though it adds one more object to clean up on failure.

Exit codes decide whether you fail open

The script's exit status is the verdict: zero accepts, non-zero rejects. The pattern that causes trouble is a shell script that accepts only when the RADIUS reply contains a specific acceptance token and returns failure for everything else, because that collapses three different events into one answer. A wrong password, an unreachable server and a malformed reply all become the same rejection.

Split those cases in the script even if the hook can only return accept or reject. Log the backend outcome separately, alert on timeouts and transport errors, and make it obvious in monitoring when authentication is failing because the service is down rather than because users are mistyping.

Two habits keep the rest of it sane. If RADIUS is the authority, it should be the only place a password is checked, because a shadow table creates two answers to the same question and the second one is an attack surface nobody watches. And remember the hook is called again on every retry, so a slow script combined with an impatient client becomes an amplifier for password guessing unless you add delay or a policy on the RADIUS side.

What you give up when the certificate goes away

Many deployments in this shape switch off client-certificate checking entirely, leaving a username and a password as the whole of the client's claim. What disappears with that setting is certificate-based identity. The channel remains encrypted, but the only thing naming the client is the RADIUS verdict. Anything downstream that used to key on a certificate subject now has to key on the username instead.

Keep control-channel protection in place so an unauthenticated peer cannot probe or flood the handshake, and use username-as-common-name when per-user configuration depends on the common name. Managed gateways often solve this differently, terminating certificate authentication at a gateway that speaks EAP-TLS to RADIUS, in which case none of these server directives apply. Copy the model your own platform documents, not the one from a neighbouring product. The directives that matter differ enough that mixing them produces a configuration which parses and then authenticates nobody.

Ingingo zihuye