SSH Hardening for Exposed Services: The Mobile Lab Edition
TL;DR: You want to SSH into your phone as a lab box. A phone is not a VPS, and hardening it the desktop way will lock you out. This guide hardens the SSH remote-control channel on Termux (OpenSSH 10.5p1): a locked-down sshd_config with key-only auth, built-in per-source penalties, ed25519 keys, an honest look at what fail2ban can and cannot do on stock Android, Termux:Boot + runit persistence, and a hardened client config on your laptop. You'll need Termux from F-Droid, the openssh and termux-api packages, and a second device to connect from.
The phone had been a headless lab box for three days. At 02:17 on a Tuesday, my laptop started logging kex_exchange_identification: Connection closed by remote host. Every few seconds, for twenty minutes.
I'd done everything the VPS tutorials said. Port 8022. Passwords off. Fail2ban installed and smugly running. Except on Android there was no iptables for fail2ban to write to, no root to grant it, and no syslog daemon to read. The scanner on the other end of the Internet wasn't even brute-forcing me; it was filling the connection table faster than an unprivileged phone process could drain it. My "hardened" box had a steel front door and no walls at all.
That night taught me the rule this guide hangs on: on a phone, SSH hardening is a different threat model, and the VPS playbook will burn you. Every option below is one we've run on a stock, non-rooted Android device in our lab, and the whole thing is written the way I wish that Tuesday had gone.
⚠ Ethical framing up front: exposing a service means letting strangers' packets touch your device. Only run sshd on networks you control — your own WiFi at home, a lab VLAN, or a VPN. Never port-forward 8022 from your router to the Internet. On stock Android you have no user firewall, so the config below is the firewall.
Level 1 — The Stock Baseline: What Termux Gives You Before You Touch Anything
Termux's OpenSSH (10.5p1 in the main repo, checked against the package index) reads its config from $PREFIX/etc/ssh/sshd_config — that's /data/data/com.termux/files/usr/etc/ssh/sshd_config on disk. Not ~/.ssh/, which is the classic wrong answer that earns you an evening of "which config is sshd even reading?" The stock file even ships an Include for sshd_config.d/*.conf; we're not using it, because we overwrite the whole file and keep the result deterministic.
Before the config, know your battlefield. Every hardening checklist written for a cloud box assumes things your phone simply doesn't have:
| Capability | VPS (Debian/Ubuntu) | Stock Android + Termux |
|---|---|---|
| Firewall you control | ✅ nftables / ufw, root-owned | ❌ No iptables access without root; apps can't filter inbound |
| Privileged ports (<1024) | ✅ bind port 22 | ❌ Must use 8022 (Termux's default) |
| Daemon supervision | ✅ systemd, always restarts | ⚠ Only if you build it: Termux:Boot + runit/wake-lock |
| Swap under memory pressure | ✅ | ❌ Non-root Android cannot add swap — LMKD may SIGKILL sshd |
| Network position | ✅ Public IPv4/IPv6 | ❌ NAT behind router, CGNAT on cellular, possibly IPv6-only RA |
| Battery governor | ✅ always on | ❌ Doze + OEM killers — daemon silently dies unless exempted |
| Logging | ✅ journald / syslog | ⚠ No syslog daemon by default — logs vanish unless you point sshd at a file |
No firewall, no swap, no supervisor, no logs by default. Each hardening decision below compensates for exactly one of those missing pillars, and the sshd config is the perimeter because you don't get another one.
# Show the real path, then back up the stock config
echo "$PREFIX/etc/ssh/sshd_config"
cp "$PREFIX/etc/ssh/sshd_config" "$PREFIX/etc/ssh/sshd_config.bak"
# Sanity check what sshd will actually use
sshd -T | head -5
sshd -T is your truth teller: it parses the file and prints the effective config, so you can confirm the path and see what's actually active before you change anything. That's the Level 1 checkpoint — run it, read it, then move up.
Level 2 — The Hardened sshd_config
Here's the full config we run in the lab. Note the unquoted heredoc: $(whoami) expands to your real username (u0_a123 style) instead of being written literally. Get that wrong and you'll lock yourself out — we've done it, more than once.
ALLOWED_USER="$(whoami)" # e.g. u0_a123 — bake your real user in
cat > "$PREFIX/etc/ssh/sshd_config" <<EOF
# ---- Termux mobile-lab SSH hardening (OpenSSH 10.x) ----
Port 8022
AddressFamily inet
# ListenAddress 192.168.1.50 # bind to ONE wifi IP — uncomment only if your IP is static
PermitRootLogin no # Termux runs non-root anyway; belt and suspenders
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
MaxAuthTries 3
LoginGraceTime 30
MaxStartups 10:30:60
# PerSourcePenalties yes # default since OpenSSH 9.8 — shown for documentation
AllowTcpForwarding no # disable tunnels by default (see Match block)
AllowAgentForwarding no
X11Forwarding no
PermitTunnel no
ClientAliveInterval 30
ClientAliveCountMax 4
UseDNS no
GSSAPIAuthentication no
UsePAM no
SyslogFacility AUTH
LogLevel VERBOSE
# Keep file transfer alive — overwriting the stock config drops this line,
# and scp silently breaks (OpenSSH 9.0+ scp uses the SFTP subsystem).
# rsync is unaffected — it uses remote-shell transport, not SFTP.
Subsystem sftp ${PREFIX}/libexec/sftp-server
AllowUsers ${ALLOWED_USER}
# Re-enable TCP forwarding ONLY from your home LAN
Match User ${ALLOWED_USER} Address 192.168.1.*
AllowTcpForwarding yes
EOF
sshd -t && echo "config OK"
Run sshd -t before restarting — it parses the file and refuses to start on errors. Then reload and verify the effective values:
pkill sshd 2>/dev/null; sshd
ss -tln | grep 8022 # confirm listening
sshd -T | grep -E 'passwordauthentication|permitrootlogin' # verify effective values
What Each Option Actually Does
| Option | What it does | Why it matters on mobile |
|---|---|---|
PasswordAuthentication no | Refuse password logins entirely | No passwords to brute-force; the single highest-value line here |
KbdInteractiveAuthentication no | Block keyboard-interactive auth (the modern name for challenge-response) | Closes the fallback path some clients use for passwords. ChallengeResponseAuthentication has been a deprecated alias of this since OpenSSH 8.7 — the old name still parses, but use the new one |
PermitRootLogin no | No root over SSH | Termux is non-root anyway; stops any future "root via sshd" accident |
AllowUsers u0_a123 | Whitelist which accounts may log in | Termux only has your one user, but explicit beats implicit |
ListenAddress + AddressFamily inet | Bind to IPv4 only, optionally one IP | On a NAT'd phone there's rarely a routable v6; binding avoids surprise exposure on v6 link-local |
MaxAuthTries 3 | 3 auth attempts per connection | Throttles key-guessing before your log even fills up |
LoginGraceTime 30 | Seconds allowed to complete auth | Kills half-open "connect and stall" sessions that eat connection slots |
MaxStartups 10:30:60 | Drop new connections when unauthenticated sessions pile up (start 10, drop probability up at 30, full reject at 60) | Your first line of rate limiting against connection floods — and a built-in one at that |
PerSourcePenalties (default) | Ban client addresses that repeatedly fail auth, for escalating windows | The fail2ban replacement — on by default since OpenSSH 9.8, no root, no firewall needed |
ClientAliveInterval 30 / ClientAliveCountMax 4 | Probe dead peers; drop after ~2 min silent | Frees slots held by clients that vanished (WiFi roam, laptop sleep). This pair replaces the deprecated TCPKeepAlive |
AllowTcpForwarding no / AllowAgentForwarding no / X11Forwarding no / PermitTunnel no | Disable tunnels, agent forwarding, X11 and tun devices | Your phone has nothing to forward from a security view; re-enable per-LAN via Match when you need reverse tunnels |
UsePAM no | Disable Pluggable Authentication Modules | Termux builds OpenSSH without PAM, so sshd just warns "unsupported option" and moves on — keep the line anyway so the same config works on a PAM system |
LogLevel VERBOSE / SyslogFacility AUTH | Detailed auth logging under the AUTH facility | You'll need the fingerprints and key names for forensics — if you capture logs at all (see "Where this bites you") |
One trade-off to name: we keep compression enabled. The delayed value you'll see in old guides is still accepted — the current man page calls it a legacy synonym for yes — and compression only engages after authentication completes. OpenSSH 9.9 removed pre-auth compression support entirely, so the old CPU-amplification angle is gone; what's left helps on slow mobile links.
Match Blocks: Forwarding Only From Home
The Match block is the surgical tool VPS guides rarely mention. Above, we disable TCP forwarding globally, then re-enable it only for your user from your home LAN. Result: reverse tunnels (ssh -R) work when you're at home, and do nothing anywhere else. If you ever need the phone to reach back out — say, to tunnel lab traffic home — that's the pattern: scoped, not global.
Level 3 — Keys: ed25519, and Purge the Rest
Generate your key pair on the laptop (the device you'll connect from), then push the public half to the phone. Since OpenSSH 9.5, ssh-keygen generates ed25519 keys by default — and that's the right call:
| Key type | Security status (2026) | Verdict |
|---|---|---|
| RSA 1024 | Broken in practice; deprecated for years | ❌ Regenerate now |
| RSA 2048 | ~112-bit security (NIST estimate), still widely accepted, but slower for every handshake than ed25519 | ⚠ Works, but why |
| RSA 4096 | Fine, but heavy on a phone's CPU for every login | 😐 Acceptable, slow |
| ed25519 | ~256-bit security, tiny keys (51-byte public blob, 68 characters once base64-encoded), fast verify | ✅ Use this |
# On your LAPTOP — ed25519, passphrase-protected
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_ed25519 -C "lab-laptop"
# Copy to the phone (this runs ssh-copy-id over the still-working stock config)
ssh-copy-id -p 8022 -i ~/.ssh/id_ed25519.pub u0_a123@192.168.1.50
-a 100 raises the KDF rounds from the default 16 — the passphrase is your laptop-side key's last line of defense if the machine is stolen. One more modern default worth knowing: since OpenSSH 10.0, the client negotiates the post-quantum hybrid key exchange mlkem768x25519-sha256 by default, so your laptop-to-phone channel is already forward-secure against quantum decryption. No config needed.
Purge Weak Keys From authorized_keys
Old guides and old you may have left RSA keys in ~/.ssh/authorized_keys. Check, then prune — an old 1024-bit key is a back door wearing a trench coat:
# See every key you currently trust on the phone
awk '{print $1, $3}' ~/.ssh/authorized_keys
# Remove anything that isn't ssh-ed25519
# List first:
grep -n -v '^ssh-ed25519' ~/.ssh/authorized_keys
# Then delete those lines and lock the file:
sed -i '/^ssh-ed25519/!d' ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh
Test the prune honestly: log in from the laptop and check which key was used. ssh -v shows "Offering public key", and LogLevel VERBOSE on the server records the accepted key's fingerprint. Match them. If the only key that works is ed25519, you're done — that's the Level 3 checkpoint.
Level 4 — Rate Limiting Without Root: The Honest fail2ban Story
Here's the part most mobile guides get wrong. fail2ban is not in Termux's repositories — we checked the package indexes for main, x11, root, and community before writing this. Even if you pip-install it or run it inside a proot-distro container, its whole enforcement model is writing iptables/nftables rules, and stock Android gives unprivileged apps exactly none of that. A "fail2ban on Termux" tutorial that doesn't mention root is a tutorial that never tested it.
⚠ fail2ban is not available in Termux's base repos, and its firewall backends require root. On stock Android, treat "fail2ban on Termux" guides as suspect. Your real rate limiters are MaxAuthTries, MaxStartups, LoginGraceTime, key-only auth, and a VPN for remote access.
The plot twist that makes all of that okay: since OpenSSH 9.8, sshd itself tracks client addresses that repeatedly fail auth — or stall without authenticating — and refuses them for escalating penalty windows. PerSourcePenalties is on by default, needs no root and no firewall access, and Termux's 10.5p1 ships it. That's your real brute-force defense on Android, and it's already running. (OpenSSH 10.x also split sshd into listener, session, and auth binaries, which shrinks the pre-auth attack surface further — another reason to stay current.)
What you can still add without root is detection without enforcement: tail your sshd log and push a notification when auth failures spike. It won't block the scanner — nothing userland can, without root — but it tells you the scanner exists, which is half the battle:
# On the PHONE — log sshd to a file first (no syslog daemon exists!)
pkill sshd; sshd -E ~/.ssh/sshd.log
# Watch for failures and notify yourself (needs termux-api + Termux:API app)
tail -F ~/.ssh/sshd.log | grep --line-buffered -i 'failed\|invalid\|refused' \
| while IFS= read -r line; do termux-notification --title "SSH probe" --content "$line"; done
Pair that with MaxStartups and the connection table stops filling up from half-open scans. This is the honest non-root equivalent of fail2ban, and it's what "no firewall" leaves you.
Level 5 — Persistence: Make It Survive Android
5.1 Battery Exemption + Termux:Boot — the daemon that survives reboots
Stock Android kills Termux within minutes of the screen going off unless it's exempted. Three layers, in this order: battery exemption (Settings → Apps → Termux/Termux:Boot/Termux:API → Battery → Unrestricted), a wake lock, and a boot script.
mkdir -p ~/.termux/boot
cat > ~/.termux/boot/start-sshd << 'EOF'
#!/data/data/com.termux/files/usr/bin/bash
# Hold a wake lock so the CPU stays up, then start sshd with file logging
termux-wake-lock
sshd -E ~/.ssh/sshd.log
EOF
chmod +x ~/.termux/boot/start-sshd
echo "done — reboot the phone to test"
⚠ Termux:Boot scripts only run if the Termux:Boot app is installed from the same source as Termux (the apps must share a signing key), and some OEMs — Xiaomi and Samsung are the usual suspects — silently block boot receivers. If nothing happens after reboot, check the battery exemption list again before blaming the script.
5.2 runit Supervision: Restart It When Android Gets Nervous
A boot script starts sshd once. termux-services (runit) supervises it and restarts it if it dies — which on a phone means "whenever the memory manager gets nervous":
pkg install -y termux-services
# sv-enable BOTH enables autostart and starts it now:
sv-enable sshd
sv status sshd
# logs go to $PREFIX/var/log/sv/sshd/current (via svlogger)
Order matters, and so does consistency. If you go the runit route, don't let the boot script start sshd itself — point it at runit's launcher instead, or you'll get duplicate listeners and a confusing ss output. The official integration is a separate boot script that sources the service launcher:
cat > ~/.termux/boot/start-services << 'EOF'
#!/data/data/com.termux/files/usr/bin/bash
termux-wake-lock
source "$PREFIX/etc/profile.d/start-services.sh"
EOF
chmod +x ~/.termux/boot/start-services
# Now remove/rename the old start-sshd script so only runit owns sshd:
mv ~/.termux/boot/start-sshd ~/.termux/boot/start-sshd.bak
Under runit, sshd runs with -D -e (foreground, stderr logging), so svlogger captures everything into $PREFIX/var/log/sv/sshd/current. That's your logging path now — don't mix it with the -E file flag, or you'll have two sshd processes fighting over the port.
5.3 cronie: Schedules That Actually Fire
cronie is available in Termux and works — with two catches: crond must be running (it is not by default), and it only fires while the process tree survives. Same pattern as sshd: start it via runit so it stays supervised.
pkg install -y cronie
sv-enable crond # enables autostart AND starts it now — same runit pattern
crontab -e
# Example crontab for the lab phone — note the % escaping and explicit PATH
PATH=/data/data/com.termux/files/usr/bin
# Daily 02:30 status snapshot, logged where you can find it
30 2 * * * /data/data/com.termux/files/usr/bin/free -m >> ~/.ssh/cron.log 2>&1
# Every 5 minutes, re-check sshd is alive (cheap, paranoid, correct)
*/5 * * * * /data/data/com.termux/files/usr/bin/pgrep sshd || /data/data/com.termux/files/usr/bin/sshd -E ~/.ssh/sshd.log
Rabbit hole I hit: cron swallowed a whole job because of the % sign — in a crontab, % means a newline to cron, so the command truncated mid-schedule. Escape it as \%. The PATH gotcha used to be real too: older cronie builds ran jobs with a bare /usr/bin:/bin PATH and pgrep came back "not found" in a silent, email-less void. Current Termux cronie sets its default PATH to $PREFIX/bin, so the explicit PATH line above is belt and suspenders now, not a requirement. Keep it anyway — it costs one line and saves a debugging hour on any other box.
Level 6 — Harden the Client Side, Pin the Host Key
The phone is only half the channel. Your laptop's ~/.ssh/config should be just as deliberate — an alias so you never type the raw IP, IdentitiesOnly yes so ssh offers only the right key (not every key in the agent, which some servers log as noise), and keepalives tuned for a phone that sleeps:
cat >> ~/.ssh/config << 'EOF'
Host lab
HostName 192.168.1.50
Port 8022
User u0_a123
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ServerAliveInterval 15
ServerAliveCountMax 3
ConnectTimeout 5
EOF
chmod 600 ~/.ssh/config
ssh lab # that's the whole login now
ssh -vvv lab # add -vvv when something breaks
ServerAliveInterval 15 keeps the NAT mapping on your router fresh and surfaces dead sessions in under a minute — the client-side twin of the server's ClientAliveInterval. For file transfer over the hardened box, scp -P 8022 file lab:~/ still works because we kept the SFTP subsystem.
First-Contact Host Key Verification
# See the phone's host key fingerprint ON the phone
ssh-keygen -lf "$PREFIX/etc/ssh/ssh_host_ed25519_key.pub"
# Then, from the laptop, compare what you're actually connecting to
ssh-keyscan -p 8022 192.168.1.50
The fingerprints must match. That's your MITM check — do it once per phone, and your known_hosts entry is then pinned. If they ever mismatch again, someone (or something) is between you and your phone. On a phone, the sshd_config is the firewall — there is no second wall, so don't build the config like there is.
Where This Bites You
1. The Locked-Out Config (Yes, We've Done This)
A quoted heredoc writes the literal string $(whoami) into AllowUsers, sshd parses it, and every login is refused. Keep a second Termux session open while you restart sshd, and run sshd -t before pkill. Already locked out? Open the Termux app directly on the phone — local session, no SSH needed — and fix the file. The phone's own terminal is your console of last resort.
2. Never Port-Forward 8022
Port-forwarding 8022 on your router turns a hardened LAN service into an Internet service with no firewall in front of it. Need remote access from outside? Run a VPN (WireGuard on the router or a cheap VPS) and SSH only through the tunnel. On stock Android there is no second layer to catch your mistake.
3. sshd "Logs" That Are Invisible
Termux has no syslog daemon, so LogLevel VERBOSE writes to a black hole by default. That's why every command above runs sshd with -E ~/.ssh/sshd.log — or, under runit, reads $PREFIX/var/log/sv/sshd/current. An empty auth log means a config error, not a quiet network. Pick one logging path and stick to it.
4. DHCP Killed Your ListenAddress
Uncomment ListenAddress with today's IP and tomorrow's DHCP lease breaks sshd entirely — it silently refuses to start. Leave it commented (rely on AddressFamily inet) and use a MAC-based static lease on your router, or accept that AllowUsers + key auth is your actual perimeter.
5. "REMOTE HOST IDENTIFICATION HAS CHANGED"
You wiped Termux data or reinstalled → host keys regenerated → laptop refuses the new key. Fix: ssh-keygen -R 192.168.1.50 to drop the stale entry, then re-verify with ssh-keyscan and accept the new fingerprint. The scary error message is the system doing its job.
6. Guest WiFi With Client Isolation
Your phone's hotspot and many guest networks block device-to-device traffic. Symptom: ping works, SSH times out, even on the same subnet. Check "client isolation"/"AP isolation" in the router — this one has wasted more lab hours than any config line in this guide.
Now Break It
Before you call this done, run the three tests that actually prove it. First: reload the config, then try a wrong key from the laptop and watch the penalty window kick in — PerSourcePenalties should refuse you for a while, and sshd -T | grep -i persource should show it's live. Second: reboot the phone and confirm sshd comes back on its own, via runit or the boot script, whichever you chose. Third: from the laptop, ssh -vvv lab and read the accepted key's fingerprint against the phone's.
Then answer one question before it answers you: what happens when your phone's IP changes? If the answer is "I'll update the config," you're not done yet. A static lease on the router, or a Match block that doesn't depend on the address, will save you a Tuesday at 02:17.
Go deeper: this guide builds on our Termux on Android (non-root) setup guide, and the hardened channel is what you'll drive for remote recon workflows or to reach a Kali container inside Termux.
Last verified: August 19, 2026. Commands and config checked against OpenSSH release notes, the sshd(8)/sshd_config(5) man pages, the Termux package index (openssh 10.5p1), and the termux-packages build scripts.
