Debian ships an OpenSSH that’s been packaged for everyone, which is to say: not really for you. The shipped sshd_config is a museum of compromises — X11 forwarding on by default, an algorithm list that still flatters clients from 2014, a single static moduli file that’s been the same on every Debian box on the planet for years, an AppArmor profile that exists in apparmor-profiles and is never loaded, a systemd unit with no sandboxing whatsoever, and Kerberos compiled in whether you use it or not because someone, somewhere, might be running Active Directory. That’s a fine posture for a Linux laptop in 2008. It’s a worse one for a server in 2026.
So we rebuilt it. The myguard OpenSSH 10.3 packages for Debian and Ubuntu give you post-quantum hybrid key exchange by default, AEAD-only ciphers, a loaded AppArmor profile, a fail2ban jail that just works, a systemd unit that actually drops capabilities, a monthly moduli refresh timer, compiler flags well past Debian’s own hardening=+all, and — the bit we’re most pleased with — three real, apt-installable server packages that let you pick the attack surface you actually need without recompiling anything.
What follows is the full tour: what we ship, why every choice was made, the apt commands, a 2026 SSH key recipe (Ed25519, security keys, certificates), and a long tail of operational tips that the Debian default should have absorbed a decade ago.
Why rebuild OpenSSH at all?
Let’s be fair to the Debian maintainers for a sentence: OpenSSH itself, the thing the OpenBSD team writes, is one of the best pieces of security software ever shipped. The problem isn’t OpenSSH. The problem is what happens to it on the way to your apt mirror.
The shipped configuration is built for an imaginary lowest-common-denominator user — a workstation that runs X11, a laptop that might suddenly need to talk to a Solaris box from 2003, an AD-joined desktop that wants Kerberos session tickets. Every default reflects that user. None of them is you.
The roll call from a stock apt install openssh-server:
X11Forwarding yes— handy on a desktop, free tunneling for an attacker on a server.KbdInteractiveAuthenticationdefaults that interact badly with PAM and become a brute-force vector the moment someone re-enables password auth “just for a minute”.- Algorithm lists that include
ssh-rsa, NIST-P ECDH,diffie-hellman-group14-sha1, and the whole CBC family, because the maintainer can’t be sure you don’t have a 2010 router on your network. - One
/etc/ssh/modulifile, byte-identical on every install on Earth, never regenerated. - An AppArmor profile sitting unused in another package.
- A
ssh.servicewith effectively noProtectSystem, noRestrictAddressFamilies, noSystemCallFilter, noCapabilityBoundingSet— because someone in #debian-mentors thinks sandboxing is too opinionated. - Kerberos linked into the one
sshdbinary you get, even though three-quarters of Linux servers will never see a KDC.
None of this is a bug. It’s the price of being a general-purpose distribution. The cost is that every serious shop ends up writing the same Ansible role to undo it, every audit firm bills the same hours to re-discover it, and the actual exposed surface of SSH on the open internet stays exactly as wide as it was in 2014. We figured that was enough.
Pick the attack surface you actually need
Debian gives you one openssh-server binary, built with everything: PAM, SELinux, audit, libedit, security keys, wtmpdb, Kerberos. You get all of it whether you wanted it or not, you get libwrap for free even though TCP wrappers haven’t been a good idea since the Bush administration, and the binary on a hardened bastion in a co-lo is bit-for-bit identical to the one on a developer’s laptop. We split it into three.
| Package | Built with | Use it when |
|---|---|---|
| openssh-server | PAM, SELinux, audit, libedit, security keys, wtmpdb. No Kerberos. | Every server you have. This is the right default. |
| openssh-server-gssapi | Everything openssh-server has, plus Kerberos / GSS-API. | You’re actually running Active Directory or an MIT KDC. If you can’t name your realm without looking it up: not this one. |
| openssh-server-minimal | No PAM, no Kerberos, no SELinux, no audit, no libedit, no security keys, no wtmpdb, no xauth. | Containers, bastion jumphosts, recovery images. authorized_keys is the only auth path. There is no PAM stack to break and no libwrap to deprecate. |
All three install /usr/sbin/sshd and Conflicts: each other, so apt swaps them transactionally. A host that starts as openssh-server and a year later gets folded into AD becomes:
sudo apt install openssh-server-gssapi
and apt removes the old binary, installs the new one, and the unit restarts. No Ansible task, no role variable, no rebuild.
The hardening drop-in, the systemd lock-down, the AppArmor profile, the fail2ban jail and the moduli refresh timer all live in a fourth package — openssh-server-common — that every flavour Depends: on. Whichever sshd you install, the same hardened defaults apply. You can’t accidentally have the gssapi flavour without the systemd sandbox; the dependency graph won’t let you.
What openssh-server-common ships
1. A hardened sshd_config drop-in that’s actually removable
We install /etc/ssh/sshd_config.d/00-myguard-hardening.conf. Because OpenSSH applies the first occurrence of any keyword and the shipped sshd_config pulls in sshd_config.d/*.conf before its own body takes effect, this drop-in wins on every conflict. The point isn’t just to be authoritative — it’s that any admin who needs to relax one knob writes a 99-something.conf next to it. No editing the main file. No conffile-rejected-on-upgrade dialog at four in the morning. The hardening is a file, not a patch.
What’s in it:
- Post-quantum hybrid KEX first:
mlkem768x25519-sha256thensntrup761x25519-sha512, thencurve25519-sha256. NIST-P ECDH and SHA-1 DH groups are gone. If “harvest now, decrypt later” is a thing, this is the one config line that matters most. - AEAD ciphers only:
chacha20-poly1305,aes256-gcm,aes128-gcm. CBC, 3DES, arcfour and plain CTR are out — they’ve been bad ideas for various lengths of time between five and twenty years. - ETM MACs only:
hmac-sha2-512-etm,hmac-sha2-256-etm,umac-128-etm. The non-ETM variants leak a length oracle; we don’t care if your 2013 client doesn’t like that. - Signatures: Ed25519, ECDSA P-256/384/521, RSA-SHA2.
ssh-rsa(which is RSA with SHA-1) is removed fromHostKeyAlgorithms,PubkeyAcceptedAlgorithmsandCASignatureAlgorithms. It’s 2026. - Auth: password off, root prohibit-password, empty passwords off, keyboard-interactive off, host-based off, rhosts ignored, known-hosts ignored.
- Forwarding: X11 off, TCP off, agent off, tunnel off, user-environment off. If you need any of these on, you’ll know what you’re doing and which override to write.
- Brute-force economics:
LoginGraceTime 30s,MaxAuthTries 3,MaxStartups 10:30:60,ClientAliveInterval 300,ClientAliveCountMax 2. - PerSourcePenalties (OpenSSH ≥ 9.8): the killer feature nobody talks about. sshd tarpits abusive sources by /24 (IPv4) or /48 (IPv6) source block, ramping from 5 seconds on an auth failure to a 1-hour cap. fail2ban still helps if you want fancy correlation, but for the simple “China is scanning port 22 again” case, you don’t even need it.
- LogLevel VERBOSE — every successful login emits the key fingerprint that authenticated it. Your SIEM will thank you the first time you have to investigate a key compromise.
2. A systemd unit that actually sandboxes
The stock ssh.service on Debian is, charitably, conservative. It runs sshd. It restarts on failure. That’s the security boundary. We drop /usr/lib/systemd/system/ssh.service.d/hardening.conf on top of it, and the systemd merge gives you:
ProtectSystem=strictwithProtectHome=read-onlyand an explicit, minimalReadWritePaths=. sshd can write to/var/log,/run/sshd,/var/lib/sssand a few other places that matter. It cannot write anywhere else. If your sshd ever decides to drop a binary in/tmpand run it, that’s a kernel-enforced “no” rather than a hope.CapabilityBoundingSet=whitelisted. sshd ships with a long list of effective capabilities it doesn’t need; we drop the boundary to the dozen that PAM and the privsep child actually use.MemoryDenyWriteExecute=yes,LockPersonality=yes,RestrictRealtime=yes. The first one is the load-bearing knob in 2026: an exploit that smuggles in shellcode now has to defeat W^X in the address space, not just bypass NX.RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK. No AF_PACKET, no AF_BLUETOOTH, no AF_XDP. sshd is a TCP daemon — it doesn’t need to learn new tricks.SystemCallFilter=@system-service @resourcesminus@privileged @debug @mount @reboot @swap @cpu-emulation @obsolete. The kernel rejects most of the syscalls a post-exploitation toolkit reaches for, before sshd’s process ever sees them.- Plus the alphabet soup:
RestrictNamespaces,ProtectKernelTunables,ProtectKernelModules,ProtectKernelLogs,ProtectClock,ProtectHostname,ProtectProc=invisible,ProcSubset=pid.
One knob we don’t set: NoNewPrivileges=yes. pam_unix and pam_systemd rely on suid helpers, and turning NNP on at the unit level breaks login on a stock distribution. If you’ve gone openssh-server-minimal and have no PAM stack to worry about, you can add it back in a 99-*.conf override — that’s exactly the situation it was designed for.
3. An AppArmor profile that’s actually installed
Debian’s apparmor-profiles package ships an sshd profile that doesn’t get loaded. It sits there. It has sat there for years. Our package drops one in /etc/apparmor.d/usr.sbin.sshd, and the postinst calls apparmor_parser -r automatically if AppArmor is active on the host. You install the package, the profile is live. Imagine that.
It ships in complain mode, deliberately. Anyone who’s done this before will tell you: AppArmor plus sshd is a tar pit. Every PAM module, every NSS backend (sssd, ldap, winbind), every login shell that might exist on the system needs an explicit allow rule. A profile that’s even slightly too tight will silently lock you out of your own machine at the worst possible moment, which on a server reachable only by SSH is also the only possible moment.
Complain mode logs would-be denials to dmesg and /var/log/audit/audit.log without blocking. Tail them for a few weeks, fix the genuine misses, then promote:
sudo aa-enforce /usr/sbin/sshd
4. A fail2ban jail snippet
We drop /etc/fail2ban/jail.d/myguard-sshd.conf. Backend is systemd-journal — no scraping /var/log/auth.log, no log-rotation games. maxretry=3, findtime=10m, bantime=1h, and bantime-incrementing on repeat offenders up to 30 days. If fail2ban isn’t installed, the file is inert. openssh-server Recommends: fail2ban, so a default apt install pulls it in. If you’ve globally disabled Recommends in your apt config, that’s on you.
5. Monthly moduli regeneration
The /etc/ssh/moduli file Debian ships is byte-identical on every install. It was generated once, a long time ago, by someone whose name you can find in git log. We install a ssh-moduli-refresh.timer that runs monthly with a 24-hour randomised delay, regenerates fresh DH moduli at 2048/3072/4096/6144/7680/8192 bits, and atomically swaps them in. The service is heavily sandboxed (idle CPU class, NoNewPrivileges, blank RestrictAddressFamilies=) because ssh-keygen -M generate is CPU-bound for tens of minutes on a small VPS and we’d rather it didn’t have the run of the place while it grinds.
Yes, you could schedule this yourself with a cron job. You haven’t, though.
6. Compiler flags past hardening=+all
Debian’s hardening=+all gets you PIE, RELRO, stack-protector-strong, FORTIFY=2. That’s a baseline. Our build appends:
-D_FORTIFY_SOURCE=3— Debian’s default is still 2; FORTIFY=3 catches a strict superset.-fstack-clash-protection— defuses stack-clash exploits at compile time.-fcf-protection=fullon amd64 — Intel CET / shadow stacks. Free CFI on any post-2020 silicon.-mbranch-protection=standardon arm64 — PAC + BTI. Same idea, different ISA.-Wl,-z,now -Wl,-z,relro— full RELRO, no lazy binding..got.pltisn’t writable at runtime.-O3 -flto=auto -fno-plt -fno-semantic-interposition -ffunction-sections -fdata-sections -Wl,--gc-sections— speed and dead-code elimination on top of the security. Same binary that protects you better also runs faster.
None of these flags is exotic. They’ve all been stable for years. They just haven’t trickled down into Debian’s hardening=+all default, which is a process problem rather than a technical one.
7. Reproducible builds
Our debian/rules pins SOURCE_DATE_EPOCH from the changelog timestamp when it isn’t already set. Two builds of the same source produce byte-identical binaries. If a malicious build server ever ships you a tampered sshd, you can prove it.
8. Autopkgtests that fail the build
Three tests run on every build, and if any of them fails the package doesn’t ship:
- sshd-hardened-defaults — runs
sshd -Ton the just-built binary, asserts every hardened knob is in effect, fails the build if a deprecated cipher, KEX or MAC ever sneaks back into the active config. - sshd-flavours-exist — all three flavour packages each ship a working
/usr/sbin/sshd. - apparmor-profile-loads — the profile parses with no warnings and loads cleanly when AppArmor is active.
Translation: the hardening can’t quietly regress between releases. The build itself enforces it.
Installing it
If you don’t have the myguard repo configured yet, follow the quick repo setup first. Then pick your flavour:
# The default. Use this everywhere except the two edge cases below.
sudo apt install openssh-server
# Active Directory or MIT Kerberos shop.
sudo apt install openssh-server-gssapi
# Bastion jumphost, container, recovery image.
sudo apt install openssh-server-minimal
Whichever you choose, apt pulls in openssh-server-common and lays down the hardened drop-in, the systemd override, the AppArmor profile, the fail2ban jail and the moduli timer. Restart sshd once:
sudo systemctl daemon-reload
sudo systemctl restart ssh
Confirm the live config is the live config:
sudo sshd -T -C user=root | grep -E '^(kex|cipher|mac|passwordauth|x11|permittun)'
If you want to switch flavours later — say, the host gets folded into AD — it’s one command:
sudo apt install openssh-server-gssapi
The Conflicts: relationship handles the removal. The systemd unit handles the restart. openssh-server-common doesn’t move, so none of your hardening churns.
The 2026 SSH key recipe
If you do nothing else after reading this: stop generating RSA keys. Ed25519 is smaller, faster, has no parameter-generation footguns, isn’t subject to the SHA-1 deprecation churn, and has been the right answer since 2014. If your ~/.ssh/id_rsa is older than your kids, today is a good day.
The one-liner
ssh-keygen -t ed25519 -a 100 -C "$(whoami)@$(hostname)-$(date +%Y%m%d)"
-t ed25519— modern Edwards-curve signature. 32-byte public key, 64-byte signature, no nonce-reuse class of bug. Boring, which is what you want.-a 100— 100 rounds of the bcrypt-pbkdf KDF on the encrypted private key. An attacker who walks off with your~/.ssh/id_ed25519still has to crack that passphrase, and 100 rounds slows brute-force by orders of magnitude. You pay it once, when ssh-agent unlocks the key.-C "..."— a comment that says which laptop generated it and when. Future you, finding three half-remembered keys in some oldauthorized_keysfile in 2029, will be grateful.
Pick a real passphrase. “I want unattended logins” — that’s what ssh-agent is for. Unlock the key once, stay unlocked for the session, no typing.
Push it to a server
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.example.com
With our package, password auth is already off, so the first key has to get there some other way: the cloud provider console, your configuration management, an existing key on a peer host. Once it’s in:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
sshd refuses to read group-writable or world-readable key files, by design. That refusal is one of the few things on a hardened sshd that bites people. Now you know.
Hardware keys, if you have one
OpenSSH speaks FIDO2 / WebAuthn natively via sk-ssh-ed25519. YubiKey, NitroKey, OnlyKey — anything with the U2F/FIDO2 spec works:
ssh-keygen -t ed25519-sk -O resident -O verify-required \
-C "yubikey-$(date +%Y%m%d)"
ed25519-sk— the security-key variant. The private scalar never leaves the token; there is no software-only copy to steal.-O resident— the key handle is stored on the token itself. Pull it back onto a fresh laptop withssh-keygen -K.-O verify-required— every authentication needs a PIN and a touch. Don’t skip this one. An attacker with physical access to the token can otherwise log in by tapping it, which is roughly the opposite of what you bought a hardware key for.
openssh-server and openssh-server-gssapi build with --with-security-key-builtin, so sk-* works out of the box without runtime libfido2. openssh-server-minimal doesn’t — by design, since minimal is for bastion hosts and containers where the FIDO key lives on the client and the server never needs to know.
If you really must use RSA
Sometimes a legacy system insists. Generate at least 4096 bits, and remember that the signature algorithm matters more than the key type — our drop-in already drops ssh-rsa (RSA with SHA-1) and only accepts rsa-sha2-256 / rsa-sha2-512 from the same RSA key:
ssh-keygen -t rsa -b 4096 -a 100 -C "rsa-$(date +%Y%m%d)"
If a server ever rejects your RSA key with “no mutual signature algorithm”, that’s the diagnosis: your client wants ssh-rsa, the server demands SHA-2. Fix it on the client:
# ~/.ssh/config
Host stubborn-old-thing.example.com
PubkeyAcceptedAlgorithms +rsa-sha2-512,rsa-sha2-256
Tips, tricks, and things the Debian default should already do
Use SSH certificates, not authorized_keys
Past a handful of users or hosts, copying public keys around becomes an audit nightmare. SSH certificates fix it: a CA signs short-lived user certificates (“alice can log in as root on hosts matching *.prod.example.com for the next 8 hours”), and the server only knows the CA’s public key via TrustedUserCAKeys. Rotation, revocation, audit — all become trivial. Our hardening drop-in already restricts CASignatureAlgorithms to the modern set.
Minimal CA bring-up:
# on a hardened host (offline-ish, or in a vault)
ssh-keygen -t ed25519 -f ca_user_key -C "user-ca-$(date +%Y%m%d)"
# on every server, in /etc/ssh/sshd_config.d/20-userca.conf
TrustedUserCAKeys /etc/ssh/ca_user_key.pub
# issue an 8-hour certificate for alice
ssh-keygen -s ca_user_key -I alice@laptop -n alice,deploy \
-V +8h -z 1 ~/.ssh/id_ed25519.pub
The result is a ~/.ssh/id_ed25519-cert.pub alongside the user key. ssh presents it automatically. When Bob leaves the team, you don’t grep two hundred authorized_keys files — you add his cert serial to a RevokedKeys file and push that one file out.
Allowlist sources with Match
Most servers don’t need to accept SSH from the entire internet. In /etc/ssh/sshd_config.d/10-source-allowlist.conf:
Match Address !10.0.0.0/8,!192.168.0.0/16,!2001:db8::/32,*
DenyUsers *
The traditional answer was /etc/hosts.allow with TCP wrappers. We dropped --with-tcp-wrappers on purpose: libwrap has been deprecated for over a decade, upstream OpenSSH removed it in 6.7, and Debian only kept it because backwards compatibility is a religion. Match blocks and nftables do the same job, better.
Put SSH into your monitoring
The LogLevel VERBOSE in our drop-in exists for one reason: every successful login records the key fingerprint that authenticated it. That single line is the difference between knowing who got in and guessing. Pipe it to your SIEM:
journalctl -u ssh -f --output=json \
| jq 'select(.MESSAGE | test("Accepted publickey"))'
Or a quick one-shot review of this week’s logins:
journalctl -u ssh --since "1 week ago" \
| grep "Accepted publickey" \
| awk '{print $9, $11, $13}' | sort | uniq -c | sort -rn
Don’t move SSH to port 2222
It’s the oldest folklore recommendation in the book and it hides exactly nothing from a Shodan-scale scan. What actually quietens your logs is per-source rate limiting (which we ship) and fail2ban (which we ship). Keep SSH on 22 and avoid the firewall rule, the monitoring exception, the runbook footnote, and the inevitable “wait, why can’t I ssh to that host” thread on Slack.
If you must allow passwords, scope them
A legacy vendor really needs password auth. Fine. Confine it to the VPN:
Match Address 10.99.0.0/24
PasswordAuthentication yes
KbdInteractiveAuthentication yes
ProxyJump, not nested ssh
# ~/.ssh/config
Host bastion
HostName bastion.prod.example.com
User ops
Host prod-*
ProxyJump bastion
User deploy
ssh prod-web-04 now does the right thing — single TCP tunnel through the bastion, bastion never holds your private key, no agent forwarding required, no “ssh-in-ssh-in-ssh” muscle memory. Pairs naturally with openssh-server-minimal on the bastion: nothing but sshd and authorized_keys, the way bastions were meant to be.
Multiplex sessions
# ~/.ssh/config
Host *
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
The second ssh to a host within 10 minutes reuses the first one’s TCP connection and skips the entire handshake. Ansible runs faster. scp in a loop runs faster. Rapid-fire one-liners run faster. Free.
Verify host keys the first time
“The authenticity of host can’t be established, are you sure?” is a security checkpoint, not a nuisance. Get the real fingerprints on the server:
for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done
Compare the Ed25519 line to what your client prints. Better: publish them in DNS as SSHFP records and set VerifyHostKeyDNS yes in your client. Our package builds with DNSSEC SSHFP support — Debian’s dnssec-sshfp.patch is in our applied series.
Tune PerSourcePenalties for your traffic shape
Our defaults assume sshd faces the open internet. If yours sits behind a load balancer or a VPN that aggregates many real clients to one source IP, raise the limits or you’ll tarpit legitimate users:
# /etc/ssh/sshd_config.d/50-behind-lb.conf
PerSourceMaxStartups 32
PerSourceNetBlockSize 32:128
Hash your known_hosts
If your laptop gets compromised, an attacker reads ~/.ssh/known_hosts and learns every server you’ve ever connected to. They get a free reconnaissance map. Hash it:
echo 'HashKnownHosts yes' >> ~/.ssh/config
ssh-keygen -H # rewrites the existing file with hashed entries
Restrict keys with options
Per-key restrictions in authorized_keys tighten things down even further than the global config:
# backup user — only from the NAS, only running restic-server, nothing else
restrict,from="10.0.0.42",command="/usr/local/bin/restic-server" ssh-ed25519 AAAAC3Nz... backup@nas
restrict turns off agent forwarding, port forwarding, pty, X11 and user-rc; opt back in explicitly only what you need. from= pins the source IP. command= forces a specific command no matter what the client asks for. Combine all three and an attacker who steals the private key still can’t do anything but run restic-server from the NAS’s IP. That’s the goal.
When things break
“My ancient client can’t connect”
An OpenSSH from 2014 won’t negotiate our algorithm list. Three options, ranked:
- Update the client. Almost always the right answer.
- Add a
Match Addressblock that relaxes the algorithm set just for that source. - Shadow our drop-in with a 99-*.conf that adds whatever the client needs. Leave a comment that says why.
“AppArmor is blocking something”
The profile ships in complain mode, so this shouldn’t happen unless you flipped it to enforce. Check dmesg for apparmor="DENIED", run aa-logprof to generate the missing rules, and drop them in /etc/apparmor.d/local/usr.sbin.sshd rather than editing the shipped profile. Or open an issue against the package — if your stack hits the denial, others’ will too.
“sshd won’t start after upgrade”
sudo sshd -t validates the config. journalctl -u ssh -n 50 --no-pager shows the actual error. The usual cause is a leftover file in /etc/ssh/sshd_config.d/ from a previous install that references a removed keyword. Our drop-in is 00-myguard-hardening.conf; anything with a higher number wins on conflicting keys.
“I installed openssh-server-minimal and pam_systemd broke”
Working as designed: minimal has no PAM. pam_systemd session registration, pam_limits, motd, Kerberos PAM modules — none of it exists in that binary. If you need any of those, you wanted openssh-server. Minimal is for containers, bastions and recovery images, where having no PAM is the feature.
Why not just roll your own?
You absolutely can. Everything above is a few hours of work for any competent sysadmin, plus the ongoing cost of keeping it current as OpenSSH ships new algorithms, deprecates old ones, and adds knobs like PerSourcePenalties. We did the work, we keep doing it, and we ship the result as a single apt install. The autopkgtests in our build pipeline make sure the defaults don’t quietly regress between releases.
The packages are out now for Debian bookworm and trixie, Ubuntu noble and resolute. Pull them from our repository. Pick the flavour that matches your use case. File issues if you find any. Hardened SSH should have been the default a long time ago — until it is, you can have ours.