Part of my work on a client's production server runs through a Bash shell that has no tty. Not a terminal I type into, but a shell a script calls: commands go in, output comes out, and nobody can interrupt halfway through. In an environment like that, plain ssh stops at the password prompt and sits there, because that prompt is waiting on keystrokes that will never arrive.
The usual shortcuts for this case were all missing from the Git Bash I was working in. No sshpass, no plink, no expect, and no setsid either. What was left was OpenSSH's own mechanism, SSH_ASKPASS, a script ssh calls to fetch the password when it decides it cannot ask directly.
I set SSH_ASKPASS, and ssh still hung.
Three different things holding one command
What made this slow to read is that the failure looks singular. One prompt that does not move, one process that never exits. There were three things holding it, and all three had to come off in the same command.
First, a DISPLAY that is still set makes ssh try a GUI askpass, and that is where it hangs. The askpass really does get called, except ssh calls it as a program assumed to draw a dialog on screen, inside a shell that has no screen.
Second, without SSH_ASKPASS_REQUIRE=force, OpenSSH ignores the askpass when it thinks a tty exists. So there are two failure modes pointing in opposite directions that both end in silence: called as a GUI and hanging, or not called at all because ssh believes it can still ask for itself.
Third, and unrelated to askpass, there was an on-disk key the server had already rejected, and that key has to be skipped explicitly for ssh to actually reach the password path. The password path itself was genuinely available. The server offers two methods, publickey and password, and password auth is on. The password is issued by the admin on the client's side, and I decided from the start that it does not get stored in any notes of mine.
The command shape that finally worked
The fix I settled on is four things at once: drop DISPLAY, force the askpass, force password authentication, and turn pubkey off. The block below is the version that eventually made it into my runbook, which adds one option my first note did not have, NumberOfPasswordPrompts=1.
printf '#!/bin/sh\nprintf "%s\n" "<PASSWORD>"\n' > /tmp/ap.sh; chmod +x /tmp/ap.sh
env -u DISPLAY SSH_ASKPASS=/tmp/ap.sh SSH_ASKPASS_REQUIRE=force \
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no \
-o NumberOfPasswordPrompts=1 deploy@host.example 'cmd'
rm -f /tmp/ap.sh # delete immediately, it holds the plaintext passwordenv -u DISPLAY takes off the first one, SSH_ASKPASS_REQUIRE=force takes off the second, and the pair of PreferredAuthentications=password with PubkeyAuthentication=no takes off the third.
The rm -f on the last line is not tidiness. That askpass script holds the password as plain text on disk, so it has to be gone the moment the command finishes, not whenever I remember.
My own older note sets DISPLAY instead
There is one thing in my own notes that does not line up, and I would rather write it down as it is than smooth it over.
A session note dated 3 July 2026 records an SSH method that, according to that note, works headless and was used all session, on OpenSSH 10.2. Its shape is not to drop DISPLAY but to set it, with stdin redirected from /dev/null.
SSH_ASKPASS=$PWD/askpass.sh SSH_ASKPASS_REQUIRE=force DISPLAY=:0 ssh ... < /dev/nullFive days later, on 8 July 2026, the runbook I confirmed holds the opposite shape on the DISPLAY part, and the reason written there is that a set DISPLAY makes ssh try a GUI askpass and hang.
I have no controlled test isolating that one variable from the rest, so I am not going to claim I know which one decides the outcome. What I can see is that the only piece identical in both notes is SSH_ASKPASS_REQUIRE=force. What I use now is the later shape, because that is the one recorded as confirmed.
The other gate, whose symptom looks like a dead server
Access to that box has two separate gates, and the first one stands well before anything to do with passwords. Port 22 sits behind an IP allowlist in an AWS security group, while my machine's IP is residential and rotates whenever the connection comes back up.
That produces a failure mode that is very easy to misread: port 22 times out while the site serves visitors perfectly well. That is not a server problem, that is my IP having rotated out of the allowlist. Misreading this symptom once cost an hour.
Telling them apart is cheap. Hit both ports straight from Bash and compare.
timeout 8 bash -c 'echo > /dev/tcp/host.example/443' # open
timeout 8 bash -c 'echo > /dev/tcp/host.example/22' # times outIf 443 is open and 22 is dead, the machine is alive and a firewall is doing the closing. If port 22 is reachable, my IP is currently allowed and the problem lives somewhere else. On 6 July 2026 I resolved it by asking the client's admin to add one IP address to the allowlist, and it was already clear then that this would recur on every rotation.
Updating the allowlist myself, and its traps
Since 6 August 2026 the client's admin has issued AWS keys to me, so I can refresh the allowlist myself with no Slack round-trip. The script runs from Git Bash, not PowerShell.
~/bin/allow-my-ip.sh "$(curl -s https://checkip.amazonaws.com)" <user> <aws-profile>What you pass has to be the public IP. The example script from the client's side uses a 192.168.x.x address, which is a LAN address, and sending a private IP creates a useless rule while SSH keeps failing. That combination reads exactly like a server fault when the wrong part is the number we sent ourselves.
The original script also carried two bugs I had to fix first. The first is mktemp paired with file://, which cannot work under Git Bash because aws.exe is a native Windows binary and cannot resolve a /tmp/... path, failing with Unable to load paramfile. That is the same illness as the DISPLAY one above, a program standing in a different world from the shell calling it. The second is a duplicate check that never fires on any platform, because its JMESPath filters an already-projected list, so the flow always falls through to the add and exits 1 on InvalidPermission.Duplicate.
One more thing worth knowing before panicking at the contents of the security group: the key is add-only. It has AuthorizeSecurityGroupIngress but not Revoke, so old IPs pile up permanently and the client's admin prunes them by hand every week or two, by their own choice. An old IP still listed there is expected, not a bug.
Once key auth is back
That password path really only needs to work once, because the most useful thing to do inside it is install a key. In the same password session, pipe the pubkey into cat >> ~/.ssh/authorized_keys.
cat ~/.ssh/id_ed25519.pub | env -u DISPLAY SSH_ASKPASS=/tmp/ap.sh SSH_ASKPASS_REQUIRE=force \
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no deploy@host.example \
'cat >> ~/.ssh/authorized_keys'My ed25519 pubkey landed in that server's authorized_keys on 8 July 2026, and key auth has worked from the same Bash environment with -i ever since. The commands get much calmer, and for longer scripts I send them over stdin with a heredoc.
ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 deploy@host.example 'php -d display_errors=1 /dev/stdin' <<'PHP'
<?php require "/var/www/html/staging.example.com/wp-load.php"; /* ... */
PHPThat shape exists because the box has a working PHP 8.3 CLI but no WP-CLI at all, so WordPress has to be booted by hand with a require on wp-load.php, and small jobs run through php -r. One real use of it was clearing 125 login throttle transients that were keeping people out of the login page.
One rule I hold hard on this path: re-verify the docroot by grepping WP_HOME in its wp-config.php before any wp-load, and never assume it from the folder name.
grep -m1 WP_HOME /var/www/html/staging.example.com/wp-config.phpThe reason is not tidiness. That same box hosts several other client sites, and every wp-load boots exactly one site, the one whose docroot you point at. Pointing at the wrong docroot means side-effecting the wrong client, from a command that looks like it only reads.
What I took away
One hanging prompt does not mean one cause. There were three stacked here.
SSH_ASKPASS is not a switch you simply turn on. OpenSSH still reserves the right to ignore it when it thinks a tty exists, and SSH_ASKPASS_REQUIRE=force is the part that removes that right. It is the one piece that shows up in every note I have about this server.
If port 22 times out while the site serves fine, suspect the firewall and your own IP address before you suspect the server. Two tcp probes, one to 443 and one to 22, separate them in seconds.
And if an access path only needs to succeed once, spend that success on installing a key rather than on the day's actual task. An askpass script holding a plaintext password on disk is something I want alive for as short a time as possible, and the best way to shorten its life is to make sure nothing needs to call it again tomorrow.