The authenticator shows a fresh six-digit code, the VPN rejects it, and the same code verifies fine when you test from your own shell. That gap is the whole story. A PAM module that cannot open the enrollment secret returns a failure that looks identical to a wrong code, and the service environment that hides home directories is a common reason it cannot. Before loosening any permission, decide which of three situations you are in.

Which of three failures is this?

The first is a secret the module cannot read at all. The second is a secret it can read but not update. The third is a code that really is wrong, usually because of clock drift or a replayed value.

Separate them by reading the service journal and the system authentication log for one failed attempt, then confirm which PAM service the plugin loads and which module line that service uses. A generic authentication failure message does not distinguish them, and neither does a successful manual test, because your shell is not the service's environment.

The unit's filesystem view is the usual culprit when only the service fails.

systemctl show -p ProtectHome -p ProtectSystem -p ReadWritePaths [email protected]
ls -l /var/lib/vpn-otp/*/.google_authenticator 2>/dev/null | head -n 20

Option A: move the secret out of home

When the service hides /home and you would rather keep that protection, store the secret somewhere it can see. The module accepts a secret= path and a user= account that performs the file operations, which lets a central store serve many identities.

This is the tidy option, and it is the one that needs the most care, because a shared store makes a single directory the security boundary for every account.

auth required pam_google_authenticator.so secret=/var/lib/vpn-otp/${USER}/.google_authenticator user=vpn-otp

Check three things before trusting it: the account exists, the module worker can switch to it, and the files are owned by it with restrictive modes. Do not use ~ or a home-variable path together with user=, and do not disable the module's ownership checks to make a path work. Keep the password stage and the rest of the stack intact.

Option B: leave the file and open a path

If the secret must stay in the user's home, the unit needs a view that includes that one directory without exposing every home on the host. A drop-in can add a narrowly scoped bind mount under a sandboxed home.

This keeps per-user ownership as the model, at the cost of a unit change that has to be reviewed whenever the sandbox changes.

Two limits are easy to miss. A read-only path added beneath a directory the sandbox hides does not expose it, because the parent is simply not there. And the mount must be writable if the module has to update state, which brings the next option into play. Write the drop-in as a separate file rather than editing the packaged unit under /usr/lib, so a package upgrade does not silently undo it.

Option C: keep the sandbox and fix state writes

Reading the secret is only half of authentication. The module may rewrite time-skew information, replay protection or a counter, and a file that passes the read check can still fail the write.

That failure looks like a bad code at the next attempt rather than at this one, which is why it is mistaken for clock drift.

Give it a writable directory of its own and point the secret path there, rather than making the whole home writable. Confirm the directory's owner, the filesystem's free space and whether the unit restricts writes elsewhere. Then test two consecutive logins, because the second one proves the write path and the first only proves the read.

Verify as the service, not as yourself

Every check that matters has to run inside the unit's sandbox with the unit's identity.

A command that succeeds as root tells you the file exists and nothing else. A login through the actual service tells you whether the module can read the secret, use it and write back what it needs.

Finally, keep the requirement intact. Adding a null-password option, accepting read-only failures or removing the sandbox would all make the symptom disappear by changing the policy. A user who is required to present a code should still be rejected when the secret is unreachable.

Mikanda mikatakata