SSH Hardening for Exposed Services: The Mobile Lab Edition

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:

CapabilityVPS (Debian/Ubuntu)Stock Android + Termux
Firewall you controlnftables / 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.

termux
# 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.

termux
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:

termux
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

OptionWhat it doesWhy it matters on mobile
PasswordAuthentication noRefuse password logins entirelyNo passwords to brute-force; the single highest-value line here
KbdInteractiveAuthentication noBlock 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 noNo root over SSHTermux is non-root anyway; stops any future "root via sshd" accident
AllowUsers u0_a123Whitelist which accounts may log inTermux only has your one user, but explicit beats implicit
ListenAddress + AddressFamily inetBind to IPv4 only, optionally one IPOn a NAT'd phone there's rarely a routable v6; binding avoids surprise exposure on v6 link-local
MaxAuthTries 33 auth attempts per connectionThrottles key-guessing before your log even fills up
LoginGraceTime 30Seconds allowed to complete authKills half-open "connect and stall" sessions that eat connection slots
MaxStartups 10:30:60Drop 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 windowsThe fail2ban replacement — on by default since OpenSSH 9.8, no root, no firewall needed
ClientAliveInterval 30 / ClientAliveCountMax 4Probe dead peers; drop after ~2 min silentFrees slots held by clients that vanished (WiFi roam, laptop sleep). This pair replaces the deprecated TCPKeepAlive
AllowTcpForwarding no / AllowAgentForwarding no / X11Forwarding no / PermitTunnel noDisable tunnels, agent forwarding, X11 and tun devicesYour phone has nothing to forward from a security view; re-enable per-LAN via Match when you need reverse tunnels
UsePAM noDisable Pluggable Authentication ModulesTermux 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 AUTHDetailed auth logging under the AUTH facilityYou'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 typeSecurity status (2026)Verdict
RSA 1024Broken 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 4096Fine, 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 verifyUse this
bash
# 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:

termux
# 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:

termux
# 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.

termux
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":

termux
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:

termux
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.

termux
pkg install -y cronie
sv-enable crond    # enables autostart AND starts it now — same runit pattern
crontab -e
termux
# 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:

bash
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

bash
# 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.