Breaches don't happen because attackers found a zero-day in your kernel. They happen because SSH runs on port 22 with password auth enabled, because a service account has a shell it doesn't need, or because file permissions let www-data write to your cron directory. The fixes are boring, repetitive, and they work.
I've walked customers through post-breach forensics enough times to see the patterns. The entry point is almost always a weak credential, an exposed service, or a misconfigured permission that handed an attacker exactly what they needed. What follows are the nine changes that close those doors.
1. Disable SSH password authentication entirely
Password auth is the front door attackers knock on first. Brute-force attempts hit every publicly-routed server within hours of provisioning. I've seen logs with tens of thousands of failed root login attempts in a single day.
Switch to key-based authentication and turn passwords off. Generate an ED25519 key pair on your local machine:
ssh-keygen -t ed25519 -C "[email protected]"
Copy the public key to your server:
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@your-server
Then edit /etc/ssh/sshd_config:
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM no
PermitRootLogin prohibit-password
Restart SSH and test the connection from a second terminal before you close your current session. If you lock yourself out, you'll need console access through your hosting panel.
2. Change the default SSH port
Security through obscurity isn't a strategy, but it cuts noise. Moving SSH off port 22 drops automated scan traffic by ninety percent. Your logs stay readable and your fail2ban rules spend cycles on real threats instead of script kiddies.
Pick a high port between 10000 and 65535. Edit /etc/ssh/sshd_config:
Port 47823
Update your firewall rules to allow the new port and block 22:
ufw allow 47823/tcp
ufw delete allow 22/tcp
ufw reload
Restart SSH, then connect on the new port:
ssh -p 47823 user@your-server
Store the port in your ~/.ssh/config so you don't have to type it every time:
Host your-server
HostName 192.0.2.10
Port 47823
User youruser
3. Configure fail2ban with aggressive jail times
Fail2ban watches your logs and blocks IPs that trigger too many auth failures. The default configuration is too forgiving. An attacker gets ten tries before a ten-minute ban—plenty of time to rotate IPs or walk through a credential list.
Install fail2ban:
apt install fail2ban
Create /etc/fail2ban/jail.local:
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
[sshd]
enabled = true
port = 47823
logpath = /var/log/auth.log
Three failures in ten minutes earns an hour-long ban. For repeat offenders, enable incremental banning:
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 604800
Restart fail2ban and watch the ban list grow:
systemctl restart fail2ban
fail2ban-client status sshd
4. Remove unused user shells and lock service accounts
Every user account with a login shell is a potential pivot point. Service accounts—mysql, www-data, nginx—don't need shells. An attacker who compromises a web application shouldn't be able to pop a shell as www-data and start enumerating the system.
List accounts with login shells:
grep -vE ':(false|nologin)$' /etc/passwd
For any service account in that list, lock it:
usermod -s /usr/sbin/nologin mysql
usermod -s /usr/sbin/nologin www-data
usermod -L nginx
The -L flag locks the password so the account can't authenticate even if someone discovers a hash.
5. Harden kernel parameters with sysctl
The kernel ships with network stack defaults that prioritize compatibility over security. A few sysctl tweaks block common reconnaissance and amplification attacks.
Edit /etc/sysctl.conf or create /etc/sysctl.d/99-hardening.conf:
# Disable IP forwarding
net.ipv4.ip_forward = 0
net.ipv6.conf.all.forwarding = 0
# Ignore ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
# Ignore source-routed packets
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
# Enable TCP SYN cookies
net.ipv4.tcp_syncookies = 1
# Log martian packets
net.ipv4.conf.all.log_martians = 1
# Disable ICMP echo on broadcasts
net.ipv4.icmp_echo_ignore_broadcasts = 1
# Protect against time-wait assassination
net.ipv4.tcp_rfc1337 = 1
Apply the changes:
sysctl -p
These settings won't stop a determined attacker, but they close off low-hanging reconnaissance tactics and make DDoS amplification harder.
6. Restrict file permissions on sensitive paths
World-readable configuration files leak database credentials, API keys, and session secrets. Writable directories under web roots let attackers upload PHP shells. Check the obvious places first.
Lock down common config files:
chmod 600 /root/.ssh/authorized_keys
chmod 600 /etc/ssh/sshd_config
chmod 640 /etc/mysql/my.cnf
chown root:root /etc/crontab
chmod 600 /etc/crontab
For web applications, nothing under the document root should be writable by the web server except specific upload or cache directories, and those directories should never allow script execution. In Apache, drop a .htaccess file in upload directories:
<FilesMatch "\.(php|phtml|php3|php4|php5|pl|py|jsp|asp|sh|cgi)$">
Require all denied
</FilesMatch>
In Nginx, deny execution in the upload location block:
location /uploads {
location ~ \.php$ {
return 403;
}
}
7. Enable automatic security updates
Patching is the most effective defense and the one admins skip most often. Manual update schedules slip. Kernel updates sit unapplied because a reboot needs scheduling. Unattended-upgrades fixes this.
On Debian and Ubuntu:
apt install unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
Edit /etc/apt/apt.conf.d/50unattended-upgrades to enable automatic reboots for kernel updates:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
On RHEL-based systems, enable dnf-automatic:
dnf install dnf-automatic
systemctl enable --now dnf-automatic.timer
You can restrict updates to security patches only by editing /etc/dnf/automatic.conf:
upgrade_type = security
8. Use a mandatory access control system
Discretionary access controls—file permissions, user groups—rely on applications and users making correct decisions. They fail when a compromised process inherits the wrong permissions. Mandatory access control systems like AppArmor and SELinux enforce policy at the kernel level.
AppArmor ships enabled on Ubuntu. Confirm it's running:
aa-status
Most common services have profiles already. Load them:
aa-enforce /etc/apparmor.d/usr.sbin.nginx
aa-enforce /etc/apparmor.d/usr.sbin.mysqld
If a profile breaks your application, switch it to complain mode while you debug:
aa-complain /etc/apparmor.d/usr.sbin.nginx
Check /var/log/syslog for AppArmor denials and adjust the profile as needed. On RHEL systems, SELinux does the same job. Check its status:
getenforce
If it returns Permissive or Disabled, enable it by editing /etc/selinux/config:
SELINUX=enforcing
Reboot for the change to take effect. SELinux policies are complex, but the default targeted policy covers most server workloads.
9. Audit sudo access and require passwords
Sudo without a password prompt is convenient. It's also a free privilege escalation. If an attacker compromises a user account that can sudo without a password, they own root immediately.
Edit /etc/sudoers with visudo and remove NOPASSWD entries:
# Bad
admin ALL=(ALL) NOPASSWD: ALL
# Good
admin ALL=(ALL) ALL
Restrict sudo to specific commands when possible. A deployment user doesn't need full root:
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl status myapp
Enable sudo logging to track who ran what:
Defaults logfile="/var/log/sudo.log"
Defaults log_input, log_output
Review the log regularly. Unexpected sudo invocations are an early warning.
What to check first
Start with SSH and user access. Key-based auth, fail2ban, and locked service accounts stop the majority of automated attacks. Then move to permissions and kernel hardening. Finish with automatic updates and mandatory access controls.
Hardening isn't a checklist you run once. Services get added, configs drift, and new CVEs drop. Schedule quarterly reviews and test your changes in a staging environment first—especially SSH config and firewall rules.
Common questions
Does moving SSH to a different port actually help?
It won't stop a targeted attack, but it eliminates almost all automated scanning noise. Your logs become usable again and fail2ban can focus on real threats.
Should I disable root login completely?
Yes. Use a regular user account with sudo access instead. Set PermitRootLogin no in sshd_config after you've tested sudo access.
What if AppArmor or SELinux breaks my application?
Switch the profile to complain mode and review the denial logs. Most issues are fixable by adjusting the profile. Disabling MAC entirely trades security for convenience you probably don't need.
How often should security updates run?
Daily for security patches. Kernel updates need a reboot, so schedule those during maintenance windows or enable automatic reboots at off-peak hours.
Can I automate all of this with a script?
Yes, and you should. Configuration management tools like Ansible make hardening reproducible across fleets. I keep a playbook that applies all nine fixes and run it on every new server.
Lock it down now
Most breaches exploit boring misconfigurations. SSH passwords, loose file permissions, unpatched services. The fixes don't require specialized knowledge or expensive tools—they require discipline. Apply these nine changes and you've closed the doors attackers walk through every day.
