OpenVPN acts on exactly one output from a login script: the exit status. Everything else the script does is decoration. A hook can reach the right verifier, parse the response and print a friendly line while returning a status that means the opposite of what its author intended. Read the contract first and write the adapter around it, and that whole class of bug disappears.

The contract is small: identify the account that was submitted, ask the approved verifier the intended question, and accept only that verifier's documented positive result for that request. Everything else is plumbing.

The three statuses the hook can return

For auth-user-pass-verify, the OpenVPN 2.6 manual defines exit code 0 as acceptance, 1 as rejection, and 2 as deferred authentication. Those are the only three answers the server understands, and each one has a different consequence for the connection. A script that exits with the status of its last command has effectively let a logging call or a cleanup step cast the vote. Write the status explicitly in every branch, including the branches that never reached the verifier.

HTTP success and authentication success are separate layers, and conflating them is the most common way this hook goes wrong. A 202 response means the request was accepted for processing, not that a password was verified. Define the expected status, the response shape and the final verdict from the API contract you were given, and do not invent a field name because it looks plausible.

If the answer genuinely arrives later, that is the deferred path and it has to be built as one. A deferred attempt that is never completed holds resources until the timeout expires.

Read the credential file exactly as it is written

With the documented via-file method, the server writes the username and password as the first two lines of a temporary file, passes that path to the script, and removes the file when the script returns. Read those two lines as data: never source the file, never evaluate it, and never paste its contents into a command line where the process table can see it. Preserve the password byte for byte, because normalizing it the way you might normalize a username silently changes the credential. The directory used for that temporary file is a deployment decision, so confirm it exists and is not readable by everyone on the host.

Keep the verifier's own credential separate from the user's: it belongs to the gateway integration, lives in the platform's secret store, and needs only the permission to verify, so it is not evidence that any particular person authenticated. Do not let the hook become a second password verifier either. Reimplementing a hash comparison by hand is not made safe by optimizing the loop, and a maintained verifier already understands the account store's salted, costed scheme.

Decide what an unclassifiable answer means

Timeouts, transport errors, malformed responses and an unavailable backend share one property: none of them is a positive verification. The hook should fail closed for each of them and record a reason an operator can act on. That reason belongs in a protected log, not in a message returned to the person trying to connect.

Be equally careful with the paths that never reach the verifier at all. A missing configuration value, an unreadable secret or a dependency that is not installed should produce a rejection with a distinct operational marker rather than an accidental acceptance. Test those branches by removing the dependency in a lab instead of reasoning about them. Where a failure to log can change the exit status, the logging has become part of the decision and has to be made harmless.

Server configuration matters here too: user-defined scripts are honoured only when the running configuration permits them at the required security level, and that setting is a deliberate choice rather than a default to raise.

Verify the verdict, not the printed line

Build a test that inspects the exit status directly. Write a sample credential file, run the hook against it with a known-good and a known-bad account, and read the status the shell reports rather than the text the script prints. That single check catches most contract bugs before a client ever connects.

Then run the same cases through a real client against a non-production listener and confirm what the server recorded: accepted, rejected or deferred. Cover a disabled account, a wrong password, a malformed verifier response, a timeout and an unreachable verifier. If any case lands in a state you cannot name, the adapter is not finished.

Keep the mapping from verifier outcome to exit status next to the script in the repository, so the next engineer can read the contract without reverse-engineering the code.

Ebitabo ebifaananyi