Intrusion Prevention
Intrusion prevention is the layer that watches what is already happening on a running server and reacts in real time — reading logs, matching patterns, and inserting firewall blocks before an attacker gets further. It is distinct from the static hardening you did at install time: SSH key-only auth, a closed firewall, and minimal packages reduce the attack surface, but they do nothing about the host that is pounding your SSH port with 4,000 credential guesses an hour, or the bot crawling your web app for .env files. Intrusion prevention turns that ongoing noise into automatic, time-boxed bans.
The operational consequence is that prevention is reactive and only as good as the signal it reads. A tool like fail2ban cannot block what it never sees, so a service that logs nothing — or logs to a journal the tool is not watching — is invisible to it. And every automated ban is a potential self-inflicted outage: misjudge the threshold and you lock out your own monitoring, your CI runner, or yourself. The discipline is tuning the trade-off between catching attackers fast and never banning legitimate traffic.
Detection Versus Prevention
An intrusion detection system (IDS) tells you something happened; an intrusion prevention system (IPS) acts on it. The line matters because acting automatically on a noisy signal is how you take yourself offline. Host-based IDS tools like AIDE or the host engine in Wazuh/OSSEC compute a baseline of file checksums and alert when /etc/passwd, a sudoers file, or a web root changes — they detect tampering after the fact but do not stop it. Prevention tools sit on a live event stream and close the door while the attack is still in progress.
On a Linux server the dominant prevention pattern is log-driven banning: parse authentication and application logs, count failures per source IP inside a sliding window, and when a host crosses a threshold, push a kernel firewall rule that drops its packets for a set duration. The block is the enforcement; the log is the only sensor. This is cheap, effective against brute-force and scanning, and useless against an attacker who succeeds on the first try — single-packet exploits and valid stolen credentials produce no failed-login signal to count.
fail2ban on Debian and Ubuntu
fail2ban is the default tool in the Debian/Ubuntu world. It runs as a systemd service, tails log files (or reads the systemd journal), and matches lines against per-service filters — regular expressions that extract the offending IP. A jail ties a filter to a log source, a threshold (maxretry), a counting window (findtime), and a ban duration (bantime), then calls an action that installs the firewall rule. The shipped defaults are conservative and the SSH jail is the one almost everyone enables first.
Never edit jail.conf directly — a package upgrade overwrites it. Put your overrides in jail.local (or drop-ins under /etc/fail2ban/jail.d/), where they survive upgrades and take precedence. On a modern Ubuntu box, point the SSH jail at the journal rather than a file, because rsyslog may not be installed and /var/log/auth.log may not exist.
# /etc/fail2ban/jail.local [DEFAULT] bantime = 1h findtime = 10m maxretry = 5 # never lock yourself out of your jump host / monitoring ignoreip = 127.0.0.1/8 ::1 203.0.113.10 # use the systemd journal, not /var/log/auth.log backend = systemd [sshd] enabled = true # escalating bans for repeat offenders bantime.increment = true bantime.factor = 2
After editing, validate and reload without dropping existing bans using fail2ban-client reload, and confirm the jail is live with fail2ban-client status sshd, which prints the currently banned IPs and the filter's hit count. On Red Hat and Fedora the package and configuration are identical, but the SSH log identity differs and the default backend assumptions change — RHEL ships fail2ban from EPEL and the journal backend is the reliable choice there too.
The Enforcement Backend
A ban is only as solid as the firewall layer it writes to. The historical default action shells out to iptables and adds one rule per banned IP, which is fine for dozens of hosts but degrades when a jail holds thousands — every packet is checked against a linear list of rules. The modern action, nftables with named sets, stores banned addresses in a single hashed set so lookup stays constant-time no matter how many entries it holds. On current Debian and Ubuntu, prefer the nftables-multiport / nftables-allports actions.
# jail.local — use nftables sets instead of one iptables rule per IP [DEFAULT] banaction = nftables-multiport banaction_allports = nftables-allports
One caveat that catches people: if ufw manages the firewall and fail2ban writes raw nftables rules, the two layers can fight over chain ordering, and a ban inserted after a broad ufw allow rule never matches. Either let fail2ban use the ufw action so bans land in ufw's own ordering, or keep both on the same backend and verify with nft list ruleset that the ban chain is evaluated before the allow rules.
Tuning the Ban Trade-off
Every parameter trades a false negative against a false positive. A low maxretry (3) and a long bantime (24h) catch brute-force fast but will eventually ban a real user fumbling a new SSH key, a NAT gateway behind which a hundred legitimate users sit, or your own CI fleet on a bad deploy. A high maxretry and short bantime almost never block anyone real but let a slow, distributed brute-force walk through under the radar. There is no universally correct setting — there is only the setting that fits this service's traffic.
For SSH on the open internet, escalating bans (bantime.increment) are the right shape: a first offence costs an hour, a tenth offence costs days, so persistent attackers are pushed out while a one-off fumble recovers quickly. Always populate ignoreip with your management network, monitoring probes, and any source you cannot afford to lock out — a banned Nagios or Prometheus blackbox probe will page you about an outage that is entirely self-made.
fail2ban — reactive, log-driven banning that reacts to observed abuse after it starts. Choose it when the service must stay publicly reachable but you want the automated brute-force noise dropped without manual intervention.
Firewall allowlisting — a static ufw or nftables rule that permits only known source CIDRs and drops everything else. Choose it when the legitimate client set is small and stable (a fixed office or VPN range), giving far stronger protection than any log parser because attackers never reach the service at all.
VPN or bastion — remove the public exposure entirely: SSH only over a VPN or through a single hardened jump host. Choose it for administrative access, where there is no reason for the open internet to touch the port. These are layers, not alternatives — a hardened fleet runs all three.
- Editing
jail.confinstead ofjail.local— the nextapt upgradeoverwrites the file and silently reverts every threshold and jail you set. - Pointing the SSH jail at
/var/log/auth.logon a journal-only Ubuntu box where that file does not exist —fail2banstarts, finds nothing to read, and bans no one while looking healthy insystemctl status. - Leaving
ignoreipempty, then banning your own jump host, monitoring probe, or office NAT IP — and locking yourself out of the very box you were protecting. - Running the legacy
iptablesbanaction with jails that accumulate thousands of IPs — per-packet matching against a linear rule list adds measurable latency under attack. - Letting
ufwandfail2banwrite to the firewall independently, so a ban rule lands after a broad allow rule and never matches — the IP shows as banned infail2ban-client statusbut its packets still pass. - Treating
fail2banas a substitute for real authentication hardening — it slows brute-force but does nothing against valid stolen credentials, so disabling password auth and using keys still comes first. - Setting
bantimeto-1(permanent) on a public service — the ban table grows without bound and you eventually block recycled cloud or carrier IPs that now belong to legitimate users.
- Put every override in
/etc/fail2ban/jail.localor a drop-in underjail.d/, never injail.conf, so upgrades cannot revert your config. - Set
backend = systemdon Debian/Ubuntu and RHEL so jails read the journal directly and do not depend onrsyslogwriting a log file. - Populate
ignoreipwith your management CIDR, monitoring probes, and CI egress IPs before enabling any jail — verify withfail2ban-client get sshd ignoreip. - Switch
banactiontonftables-multiportwith named sets so ban lookups stay constant-time as the jail fills with thousands of IPs. - Enable
bantime.incrementon internet-facing SSH so repeat offenders escalate to multi-day bans while a one-off fumble clears in an hour. - Reload with
fail2ban-client reloadafter edits and confirm hits withfail2ban-client status sshd— a jail that reports zero matched lines is parsing nothing. - Pair prevention with a host IDS like AIDE or Wazuh that baselines
/etcand your web roots, so an attacker who slips past the ban layer still trips a file-integrity alert.
Knowledge Check
What is the core limitation of log-driven prevention like fail2ban?
- It can only act on attacks that produce a log signal it is watching — a single-packet exploit or valid stolen credentials generate no failures to count
- It can only ever ban individual IPv4 addresses one at a time and silently ignores every single inbound connection that happens to arrive at the host over IPv6 instead
- It blocks attackers in the firewall but keeps no record at all of which IPs were banned or when, leaving nothing for later forensic review
- It only ever acts against already-authenticated users inside a session and is completely blind to anonymous scanners probing the open port
Why put your changes in jail.local instead of jail.conf?
jail.confis replaced on package upgrade, whilejail.localoverrides it and survives upgradesjail.confis shipped with read-only permissions by the package and cannot be edited even by the root accountjail.localis parsed measurably faster at startup because it is preloaded into kernel memory at bootjail.confonly configures failure detection, while the actual bans must be defined separately injail.local
Why prefer the nftables banaction with named sets over the legacy per-IP iptables action?
- Banned IPs live in one hashed set with constant-time lookup, instead of one rule per IP checked linearly on every packet
nftablesbans are permanent by default and never expire on their own over time, unlike the strictly time-limitediptablesrulesiptablescan only log offending traffic and never actually drop the packets, so any bans written there end up having no real effectnftablesautomatically replicates and shares its named ban set with every other server on the same network
What is the trade-off of setting maxretry = 3 with a 24-hour bantime on public SSH?
- Brute-force is caught fast, but a real user fumbling a key or a whole NAT'd office can be banned for a day on three mistakes
- It disables key-based authentication entirely and silently forces every user account on the host back to interactive password logins
- It hard-caps the SSH daemon at exactly three simultaneous concurrent sessions regardless of which source address they happen to come from
- It actively prevents
fail2banfrom ever escalating persistent repeat offenders to longer ban durations
You got correct