SSH Hardening: Keys, Brute-Force Protection and What Fail2ban Still Buys You
Cloud

SSH Hardening: Keys, Brute-Force Protection and What Fail2ban Still Buys You

Password auth off, root login off — that's most of the story, and I've already told it. This is the rest: which key type actually matters, the PAM gotcha that quietly undoes 'disable password auth,' and an honest answer on what fail2ban is even for once there's no password left to brute-force.

Published August 11, 202611 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

Beyond disabling password auth, what actually hardens SSH — and is fail2ban still worth running once you've done that?

Generate ed25519 keys, not RSA, unless something genuinely ancient forces RSA-3072-or-higher. Disabling password auth means setting PasswordAuthentication no and PermitRootLogin no, but also checking KbdInteractiveAuthentication, because PAM can reopen a password prompt through a completely different code path even with PasswordAuthentication off. AllowUsers adds a second gate independent of keys. Moving SSH off port 22 is noise reduction, not security — say so plainly. Fail2ban's honest job, once password auth is already gone, is log hygiene and connection-flood damping, not brute-force defense, because there's no password left to force. A jump host beats hardening N boxes individually the moment N is more than one.

  • Ed25519 has been the OpenSSH-recommended default since 2015 and ssh-keygen's own default since 9.5 (2023) — RSA is a legacy accommodation, not a genuine alternative
  • PasswordAuthentication no is necessary but not sufficient — PAM's keyboard-interactive path can still prompt for a password unless KbdInteractiveAuthentication is also disabled
  • Moving SSH off port 22 does not survive contact with a real port scanner; its actual value is a quieter auth.log, not a smaller attack surface
  • Fail2ban's job changes once password auth is off — it stops being brute-force protection and becomes a log-noise filter and a cheap rate limiter
  • A jump host centralizes the one thing that matters once you run more than one server: where keys get revoked and where the audit trail lives

Evidence notes

Ed25519 as OpenSSH default

Ed25519 has been OpenSSH's recommended key type since version 7.0 (2015); ssh-keygen has generated ed25519 keys by default since OpenSSH 9.5 (October 2023). NIST's deprecation of 2048-bit RSA pushes the RSA floor to 3072 bits for anything generated now.

DOCKER-USER / PAM style gotchas are documented, not folklore

sshd's keyboard-interactive authentication path is configured separately from PasswordAuthentication in sshd_config, and PAM modules invoked under that path can still request a password even when PasswordAuthentication is set to no — this is documented sshd_config behavior, not a distro bug.

Scan speed makes port-moving a hygiene choice, not a security one

Modern scanners (masscan, zmap) enumerate all 65,535 ports on a single host in well under a minute; a moved SSH port changes what shows up in casual log noise, not what a targeted scan finds.

Password auth off, root login off — that's roughly ninety percent of what SSH hardening actually buys you, and I already covered it in how I secure a fresh VPS. That post didn't have room for the parts that generate actual disagreement: which key type is worth generating, whether AllowUsers earns its line in sshd_config, why moving SSH off port 22 keeps showing up on every hardening checklist despite doing almost nothing, and what fail2ban is honestly still for once the thing it's supposedly protecting against — a guessable password — no longer exists on the box.

Why does the key type actually matter?

Because one of them is faster to verify, harder to get wrong, and smaller to carry around, and the other one is the thing everyone generates out of habit. Ed25519 is elliptic-curve, fixed at 256 bits, and gives you security roughly equivalent to a 3072-bit RSA key at a fraction of the size — the public key fits on one line you could read over the phone. It's also been OpenSSH's own recommended default since version 7.0 in 2015, and as of OpenSSH 9.5 in October 2023, ssh-keygen generates ed25519 by default if you don't specify a type at all. If you're still typing -t rsa out of muscle memory, you're actively opting out of the default your own tooling picked for you. There is a whole operational layer above keys, brute-force protection and what fail2ban still buys you that the XenGrowth practice documents.

RSA isn't broken. It's just the legacy accommodation now, not the default choice — you reach for it when something old enough to lack Curve25519 support is on the other end, and NIST's deprecation of 2048-bit RSA means that if you're generating an RSA key today anyway, 3072 bits is the floor, not 2048. ECDSA sits in an awkward middle spot: technically fine, but it depends on NIST curves that have drawn enough cryptographic side-eye over the years that most guidance just tells you to skip straight to ed25519 instead of arguing about it.

Key type

Effective strength

Where it's used

Generate it?

ed25519

~256-bit curve, equivalent to RSA-3072

ssh-keygen default since OpenSSH 9.5

Yes — default choice for anything new

RSA-3072/4096

3072-bit minimum for 2026 generation

Legacy servers without Curve25519 support

Only when the remote end forces it

ECDSA

NIST P-256/384/521 curves

Rare, mostly historical

No — use ed25519 instead

RSA-2048

Below NIST's current floor

Old keys still in use

Rotate it out, don't generate new ones

What does 'disable password auth' actually require?

Two lines, most guides say: PasswordAuthentication no and PermitRootLogin no. Both true, both necessary, and both incomplete on their own — which is the part that trips people up because the box looks hardened and isn't quite. sshd has a second authentication path, keyboard-interactive, configured by a separate directive (KbdInteractiveAuthentication, or ChallengeResponseAuthentication on older OpenSSH versions), and PAM modules invoked under that path can still prompt for a password even with PasswordAuthentication set to no. It's not a bug and it's not distro-specific mischief — it's just a second door that the first lock doesn't cover, because sshd was written to let PAM handle its own conversation with the client independently of the password directive.

  1. PasswordAuthentication no — closes the obvious door.

  2. KbdInteractiveAuthentication no (or ChallengeResponseAuthentication no on older OpenSSH) — closes the one PAM can reopen behind it.

  3. PermitRootLogin no — not prohibit-password, which still lets root in with a key. If you genuinely need scripted root access, prohibit-password is the narrower option; if you don't, no means no path in at all, key or otherwise.

  4. UsePAM yes can stay on — PAM still handles account and session management usefully; it's the auth path specifically that needs the second directive above, not PAM wholesale.

Restart sshd, then test from a second, still-open session before closing the first — the same rule from the original checklist applies every time you touch this file, because the failure mode isn't subtle. It's a locked door with no key on either side of it. The XenGrowth resource library covers the the operations side of this side of this.

Is AllowUsers worth the extra line?

AllowUsers deploy [email protected]/8 restricts SSH logins to an explicit list of username patterns, optionally scoped to a source host or network per entry. It's a second, independent gate: a leaked key for an account that isn't on the list still doesn't get in, and an account that shouldn't have interactive shell access at all (a service account, a deploy user meant only for git operations) never has to be a candidate for SSH login in the first place. AllowGroups does the same thing at the group level, which scales better once you're managing more than a handful of accounts by hand. Neither replaces the key-and-password work above — it's a name-based filter sitting on top of an already-locked door, and its main value is turning "which accounts can even attempt SSH" from an implicit fact about who happens to have a Unix account into something written down and enforced.

What other sshd_config directives are worth setting while you're in there?

A handful of directives get skipped because they're boring, not because they're wrong. None of them are the headline change PasswordAuthentication no is, but each closes a small, specific gap, and together they cost about four more lines in a file you already have open.

  • MaxAuthTries 3 — caps how many authentication attempts a single connection gets before sshd drops it, which limits how much a single TCP session can be used to hammer at key or PAM auth before it's forced to reconnect entirely

  • LoginGraceTime 30 — the default grace period for completing authentication is generous; cutting it to something like 30 seconds closes idle half-open connections faster instead of leaving them to linger

  • ClientAliveInterval 300 and ClientAliveCountMax 2 — together they drop a session that's stopped responding after roughly ten minutes, which matters less for security directly and more for not accumulating zombie sessions on a box you rarely reboot

  • AddressFamily inet — if the box has no real IPv6 use case yet, this stops sshd from listening on an IPv6 interface nobody's watching or including in firewall rules

Is moving SSH off port 22 security, or just tidiness?

Tidiness. I'll say it flatly because most posts on this topic won't: modern scanning tools enumerate all 65,535 ports on a single host in well under a minute, and a targeted scan finds your moved port exactly as fast as it finds port 22 — the search space isn't the bottleneck, connectivity is. What changes when you move SSH to some five-digit port isn't your exposure to a determined scan, it's the volume of automated, indiscriminate connection attempts that show up in auth.log from bots that only ever check the default port because checking it costs them nothing and checking everywhere costs marginally more. Fewer of those means a smaller, more legible log — which turns out to be the actual argument for doing it, not the obscurity argument everyone reaches for instead. For the AI agents and marketing automation angle, see XenGrowth on governed AI marketing workflows.

Moving the port doesn't shrink your attack surface. It shrinks your log file. Those aren't the same benefit, and only one of them is real.

What does fail2ban still buy once password auth is already off?

Less than the name implies, and I'd rather say that directly than let "brute-force protection" do work it can't actually do anymore. Brute-forcing a password requires a password to guess. Once PasswordAuthentication is no and the keyboard-interactive path is closed too, there's nothing left for a brute-force attempt to succeed against — every login attempt that isn't a valid key fails immediately, every time, regardless of how many times it's retried. Fail2ban banning an IP after five failed attempts in that world isn't preventing a compromise that was ever going to happen; it's preventing five thousand failed attempts from becoming five million, which is a resource and log-noise problem, not a security one.

That's still worth having. A box getting hammered by a botnet's SSH module burns CPU on cryptographic handshakes it never needed to attempt, and an auth.log that fills with the same rejected IP retrying every few seconds makes it much harder to notice the one login attempt that actually matters — an AllowUsers-matching account being tried from an unexpected network, say, or a key being presented that used to work and no longer should. Fail2ban's real job at that point is keeping the log small enough that an anomaly still looks like one instead of getting lost in repetition. That's a legitimate reason to run it. It's just a different reason than the one usually given.

Scenario

What fail2ban is doing

Is it "brute-force protection"?

Password auth still enabled

Banning IPs before they exhaust the password space

Yes — this is the textbook case

Password auth off, keys only

Damping connection floods, keeping auth.log legible

No — there's no password left to force

A previously-valid key gets exposed elsewhere

Rate-limiting repeated presentation attempts, surfacing the anomaly in a smaller log

No — this is log hygiene catching a different failure

Is there anything better than a raw key-pair once you have more than one server?

SSH certificates, if you get to the point where key management itself is the annoying part. Instead of every server trusting a static list of public keys that has to be updated everywhere when someone joins, leaves, or gets a new laptop, a CA signs short-lived certificates for each user, and every server is configured to trust the CA once instead of trusting individual keys forever. Revoking access becomes not issuing a new certificate rather than hunting down and removing a key from every authorized_keys file it was ever added to, and a stolen certificate expires on its own within hours instead of remaining valid until someone notices and manually revokes it. This is genuinely more infrastructure than a single-VPS setup needs — it's the right answer once you're managing access for a team across more than a handful of boxes, not before. XenGrowth on building one SEO and GEO content system approaches this from the AI search, GEO and discovery side.

When does a jump host beat hardening every box individually?

The moment you have more than one server. Hardening N boxes individually means N places where a key gets added, N places where it has to get revoked if a laptop is lost, and N separate audit trails to check if something looks wrong. A bastion — one small, deliberately minimal host with the only public-facing SSH listener, everything else behind it accepting SSH only from the bastion's internal address — turns that into one place. OpenSSH's native ProxyJump (ssh -J bastion target, or a corresponding config block) gets you there without manually forwarding an agent socket to an intermediate host, which matters: agent forwarding through a jump host you don't fully trust hands that host the ability to use your loaded keys for the duration of the session, where ProxyJump just tunnels the connection through without exposing the agent to it at all. If none of your boxes have a public IP to begin with — most self-hosted setups behind CGNAT or a residential connection — the bastion pattern and the port-22-exposure question both become moot in a different way, which is the actual subject of Cloudflare Tunnel vs. a reverse proxy.

Rank these by leverage and the list gets shorter than it looks. Key type barely matters if password auth is still on. Disabling password auth without also closing the keyboard-interactive path is half a fix that looks like a whole one. AllowUsers and a bastion are the layer that actually starts mattering once there's more than one account or more than one box. Fail2ban and a moved port are hygiene — worth doing, badly oversold, and neither one is what's standing between your server and a compromise. The thing standing between your server and a compromise is still the same line it was in the first post: PasswordAuthentication no, done correctly, on every path that leads to it.

Further reading from XenGrowth

Where this work meets go-to-market

Working on keys, brute-force protection and what fail2ban still buys you inside a commercial team? XenGrowth's marketing operations practice publishes operator guides on the revenue side of this work.

Past the first two lines

Five questions on the parts of SSH hardening past PasswordAuthentication no. Answers and reasoning at the end.

1 / 5
You've set PasswordAuthentication no and PermitRootLogin no. Is password-based login now fully closed?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

SSHSSH HardeningFail2banEd25519VPS SecurityLinuxSelf-Hostingcloud

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

How I Secure a Fresh VPS Before Deploying Anything

A brand-new VPS gets scanned within minutes of getting an IP address. Here's the exact order I run through before a single container touches the box — and which of these steps are real protection versus which ones are just theatre.

Navigate

The Security Mistakes I See New Self-Hosters Make

These aren't rare. They're the same seven patterns, documented in breach reports, CVE databases, and botnet postmortems, showing up on new self-hosted boxes on a loop — because the defaults that make setup fast are the same defaults that make a box exploitable.

Navigate

Don't Self-Host Until You Understand These 7 Things

This isn't a gate to keep you out. It's a readiness check — seven things worth being honest with yourself about before you're the one holding the pager, because a managed platform is still the right call for a lot of people right now.

Navigate

Cloudflare Tunnel vs Reverse Proxy: Which One Should You Use?

One of these opens no inbound ports and works behind CGNAT. The other is simpler, portable, and doesn't ask you to trust a daemon or a vendor's uptime with every request. Neither one is the obviously correct default — the right answer depends on which failure you'd rather own.

Navigate

What Cloudflare's Free Tier Actually Does for a Self-Hosted App

Cloudflare's free plan gets recommended for every self-hosted setup, usually without anyone saying what it doesn't cover. Here's what $0 genuinely buys a one-person VPS, checked against 2026 pricing, and exactly where it stops.

Navigate

How I Know When My VPS Is About to Crash

By the time a VPS actually falls over, the interesting information happened minutes or hours earlier, in numbers most dashboards don't even show by default. Free memory isn't one of them. Here's what actually leads a crash, and why the metric everyone checks first is often the one that lied.

Navigate

How I Use Cloudflare to Hide My Origin Server

Turning on the orange cloud doesn't hide anything by itself. It hides your origin only if you also make the origin refuse to talk to anyone who isn't Cloudflare — and there are at least four ordinary ways your real IP gets out anyway if you skip that part.

Navigate

How I Self-Host PostgreSQL for My SaaS (and When I Wouldn't)

Running Postgres in a container is easy. Running it in a way that survives a redeploy, a full disk, and an eventual major-version upgrade is the actual job. Here's the setup, tuned against Postgres's own defaults, and the honest list of where managed wins outright.

Navigate

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Navigate

The Backup Strategy Every Self-Hosted SaaS Needs (3-2-1, Applied)

3-2-1 is easy to nod along to and easy to get wrong in the specific way that only shows up on the day you need it. Here's what it actually means for one VPS running Postgres and Docker volumes, not the generic version you've already skimmed past twice.

Navigate