Touch ID password injection for SSH prompts via Hammerspoon
2026-09-28 (2w ago)2 views
#hammerspoon#macos#security#ssh#touchid
I SSH into a bunch of internal VMs that ask for my work password, sometimes twice: a jump host, then another prompt once it hops into the actual server. Copy-pasting out of a password manager every time got annoying after a year and a half of this. So I wanted something more natural (dare I say): hit a hotkey, Touch ID, password gets typed for me.
Attempt 1: sudo -v as a Touch ID trigger
macOS can gate sudo behind Touch ID through a PAM module, pam_tid.so, enabled via /etc/pam.d/sudo_local. So my plan was to call sudo -v purely as an auth check (it doesn't run anything privileged) and only reveal/type the password if it succeeds:
sudo -v || exit 1
PW=$(gopass show -o corp/password)
osascript -e "tell application \"System Events\" to keystroke \"$PW\""Didn't work. My Mac is corporate-managed, and an EPM (endpoint privilege management) agent sits earlier in /etc/pam.d/sudo and it always wins so sudo -v just falls back to a typed password prompt every time instead of TouchID. God I hate enterprise security software.
Attempt 2: LocalAuthentication directly
Skip sudo entirely. The framework behind every native Touch ID sheet on macOS is LocalAuthentication, and it's callable directly. There's no shell CLI for it, so it needs a tiny compiled binary:
import LocalAuthentication
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
exit(1)
}
let semaphore = DispatchSemaphore(value: 0)
var success = false
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "unlock password") { result, _ in
success = result
semaphore.signal()
}
semaphore.wait()
exit(success ? 0 : 1)swiftc touchid-auth.swift -o touchid-authWorked on the first try! No system files touched.
Wiring it into Hammerspoon
hs.hotkey.bind({ "cmd", "alt" }, "p", function()
local _, ok = hs.execute(os.getenv("HOME") .. "/bin/touchid-auth")
if not ok then
hs.alert.show("Touch ID failed")
return
end
local pw, pwOk = hs.execute(
'env -i HOME="$HOME" PATH="/opt/homebrew/bin:/usr/bin:/bin" /opt/homebrew/bin/gopass show -o work/password'
)
if not pwOk then
hs.alert.show("gopass lookup failed")
return
end
pw = pw:gsub("\n$", "")
hs.eventtap.keyStrokes(pw)
hs.alert.show("Password typed ✓")
end)Two things slowed it down at first, both fixed by not calling hs.execute(cmd, true). That flag runs a full login shell, sourcing .zprofile/.zshrc on every single hotkey press, dead weight for the Touch ID binary, which needs zero environment. gopass does need something (a GPG agent socket, basically), but a bare env -i HOME=... PATH=... gets it there just as reliably as a full login shell, minus the startup cost.
The first version typed the password via osascript ... keystroke, which needs macOS's Automation permission to control System Events. That permission attaches to whichever process actually makes the AppleScript call: the spawned osascript subprocess, not Hammerspoon. So it never even shows up in System Settings to grant. Switching to hs.eventtap.keyStrokes types through the Accessibility API Hammerspoon already has access to, and the permission problem disappears entirely.