Secure SSH Login: Disable Passwords, Change Port, and Restrict IP
This guide walks you through a step‑by‑step hardening of SSH on RHEL/Ubuntu servers, covering password‑authentication disabling, port migration, IP‑based access control, SELinux labeling, firewalld rule updates, backup procedures, verification from alternate terminals, rollback planning, and ongoing audit practices.
Define boundaries before changing configuration
Disabling password authentication removes weak‑password attack vectors; changing the SSH port only reduces Internet‑wide scan noise; restricting source IPs provides the strongest security benefit but requires a stable management network, bastion host, or VPN. Verify that cloud console, VM console, out‑of‑band management, or on‑site rescue access are available before proceeding, and record the current allowed users, source CIDRs, ports, and any jump‑host chains.
hostnamectl
who -u
ip -brief address
ip routeUse who -u to see active sessions and their source addresses, and ip route to identify the default gateway. Do not rely solely on the shell prompt to determine the host.
Verify the OpenSSH service unit
On RHEL 8/9 the SSH daemon runs as sshd.service. Check the package version and service status:
rpm -q openssh-server
sshd -V 2>&1 | head -n 1
systemctl status sshd --no-pager
systemctl cat sshdSome OpenSSH releases write version information to stderr, so redirect 2>&1. systemctl cat reveals any drop‑in files that may override defaults.
Check listening ports and processes
ss -lntp | awk 'NR==1 || /sshd/'
sudo lsof -nP -iTCP -sTCP:LISTEN | grep -E 'sshd|:NEW_PORT'If the desired port is already in use, choose a different free port; do not kill unknown processes to free the port.
Validate key‑based login before disabling passwords
Ensure the administrative account exists, is unlocked, has a valid shell, and that authorized_keys permissions are correct:
getent passwd ADMIN_USER
sudo passwd -S ADMIN_USER
sudo namei -l /home/ADMIN_USER/.ssh/authorized_keys
sudo stat -c '%U:%G %a %n' \
/home/ADMIN_USER \
/home/ADMIN_USER/.ssh \
/home/ADMIN_USER/.ssh/authorized_keysTypical permissions: directory 700, file 600, owned by the target user; StrictModes yes will reject mismatched permissions.
Create and install an Ed25519 key (or RSA if FIPS required)
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/SERVER_ALIAS_ed25519 -C 'ADMIN_USER@SERVER_ALIAS'
ssh-keygen -lf ~/.ssh/SERVER_ALIAS_ed25519.pubSet a passphrase (the -a 100 option increases KDF rounds without affecting authentication speed) and store the private key on a controlled workstation or in an SSH agent.
Copy the public key to the server using the existing login method
#!/usr/bin/env bash
set -euo pipefail
ADMIN_USER="ADMIN_USER"
PUBKEY_FILE="PUBKEY_FILE"
SERVER="SERVER_IP"
ssh-copy-id -i "${PUBKEY_FILE}" "${ADMIN_USER}@${SERVER}"
ssh "${ADMIN_USER}@${SERVER}" 'umask 077; chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys'If ssh-copy-id is prohibited, distribute the key via a configuration‑management system instead of chat or ticket messages.
Force the client to use the specific key and verify password authentication is disabled
ssh -vv \
-o PreferredAuthentications=publickey \
-o PasswordAuthentication=no \
-o IdentitiesOnly=yes \
-i ~/.ssh/SERVER_ALIAS_ed25519 \
ADMIN_USER@SERVER_IPDebug output should show the server accepting the key and establishing a session. If a password prompt still appears, inspect the server logs before disabling passwords.
Identify the effective sshd configuration
OpenSSH configuration may be split across Include directories and Match blocks. Use sshd -T to view the parsed effective settings:
sudo grep -RInE '^[[:space:]]*(Include|Port|PasswordAuthentication|KbdInteractiveAuthentication|PubkeyAuthentication|PermitRootLogin|AllowUsers|AllowGroups|Match)\b' /etc/ssh/sshd_config /etc/ssh/sshd_config.d 2>/dev/null
sudo sshd -T | grep -E '^(port|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitrootlogin|usepam) 'Simulate a real connection with sshd -T -C user=ADMIN_USER,addr=MGMT_IP,host=HOSTNAME to see how Match clauses affect the result.
Create a timestamped backup and add a drop‑in hardening file
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/root/sshd-backup-$(date +%Y%m%d-%H%M%S)"
install -d -m 0700 "${BACKUP_DIR}"
cp -a /etc/ssh/sshd_config "${BACKUP_DIR}/sshd_config"
if [[ -d /etc/ssh/sshd_config.d ]]; then
cp -a /etc/ssh/sshd_config.d "${BACKUP_DIR}/"
fi
sha256sum "${BACKUP_DIR}/sshd_config" > "${BACKUP_DIR}/SHA256SUMS"
printf 'backup=%s
' "${BACKUP_DIR}"RHEL supports drop‑in files under /etc/ssh/sshd_config.d/*.conf. Ensure the main configuration contains an Include directive; otherwise the drop‑in will not be loaded.
# /etc/ssh/sshd_config.d/60-login-hardening.conf
Port NEW_PORT
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
UsePAM yes
MaxAuthTries 3
LoginGraceTime 30
AllowGroups ssh-adminsVerify that the target admin user belongs to the ssh-admins group:
getent group ssh-admins
id ADMIN_USER
sudo usermod -aG ssh-admins ADMIN_USER
getent group ssh-adminsApply SELinux port labeling (RHEL enforcing)
getenforce
sudo semanage port -l | grep '^ssh_port_t'
sudo semanage port -l | awk -v p='NEW_PORT' '$1=="ssh_port_t" && $0~p {print}'
# If semanage is missing:
sudo dnf install -y policycoreutils-python-utils
sudo semanage port -a -t ssh_port_t -p tcp NEW_PORTWhen rolling back, remove the label only if it was added in this change and no other sshd instance depends on it:
sudo semanage port -d -t ssh_port_t -p tcp NEW_PORTUpdate firewalld rules
Identify the active zone and interface, then add a rich rule that allows only the management CIDR to the new port:
sudo firewall-cmd --state
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --get-zone-of-interface=IFACE
sudo firewall-cmd --zone=ZONE --list-all
sudo firewall-cmd --zone=ZONE \
--add-rich-rule='rule family="ipv4" source address="MGMT_CIDR" port port="NEW_PORT" protocol="tcp" accept'
sudo firewall-cmd --zone=ZONE --list-rich-rules
# Verify before making permanent:
sudo firewall-cmd --zone=ZONE \
--add-rich-rule='rule family="ipv4" source address="MGMT_CIDR" port port="NEW_PORT" protocol="tcp" accept' --permanent
sudo firewall-cmd --reloadIf an IPv6 management network exists, add a corresponding rule with family="ipv6". Do not delete the old port rule until the new path is verified.
Reload sshd without terminating existing sessions
sudo systemctl reload sshd
sudo systemctl status sshd --no-pager
sudo ss -lntp '( sport = :NEW_PORT )'
sudo journalctl -u sshd --since '-5 minutes' --no-pagerPrefer reload because it usually preserves active connections; if the unit does not support reload, consider restart. Syntax errors are reported via systemd logs and sshd -t.
Complete the verification loop from a separate terminal
ssh -p NEW_PORT \
-o PreferredAuthentications=publickey \
-o PasswordAuthentication=no \
-o IdentitiesOnly=yes \
-i ~/.ssh/SERVER_ALIAS_ed25519 \
ADMIN_USER@SERVER_IP
id
sudo -n trueThen test that password authentication truly fails:
ssh -p NEW_PORT \
-o PubkeyAuthentication=no \
-o PreferredAuthentications=password,keyboard-interactive \
-o NumberOfPasswordPrompts=1 \
ADMIN_USER@SERVER_IPIf login succeeds, investigate Match blocks or other authentication methods still active.
Audit server logs for evidence
sudo journalctl -u sshd --since '-15 minutes' --no-pager | grep -E 'Accepted|Failed|Invalid user|Connection|error|refused'
sudo ausearch -m AVC,USER_LOGIN -ts recent -i 2>/dev/nullCommon reasons for public‑key rejection include wrong permissions, SELinux context, algorithm policy, or AllowGroups restrictions. Fix contexts with:
sudo restorecon -RFv /home/ADMIN_USER/.ssh
sudo ls -lZ /home/ADMIN_USER/.ssh
sudo sshd -T -C user=ADMIN_USER,addr=MGMT_IP,host=HOSTNAME | grep -E 'authorizedkeysfile|pubkeyauthentication|allowgroups'Finalize permanent rules and retire the old entry
sudo firewall-cmd --permanent --zone=ZONE \
--add-rich-rule='rule family="ipv4" source address="MGMT_CIDR" port port="NEW_PORT" protocol="tcp" accept'
sudo firewall-cmd --reload
sudo firewall-cmd --zone=ZONE --list-rich-rules
# Verify old service/port before removal:
sudo firewall-cmd --zone=ZONE --query-service=ssh
sudo firewall-cmd --permanent --zone=ZONE --query-service=ssh
sudo firewall-cmd --zone=ZONE --list-ports
sudo firewall-cmd --zone=ZONE --list-rich-rules
# Remove old ssh service if it was the only listener:
sudo firewall-cmd --permanent --zone=ZONE --remove-service=ssh
sudo firewall-cmd --reload
sudo firewall-cmd --zone=ZONE --query-service=ssh
# Confirm sshd no longer listens on 22:
sudo ss -lntp '( sport = :22 )'Upstream access control and automation impact
Cloud security groups or hardware firewalls must also be limited to the management CIDR and the new port. Update any cron jobs, systemd timers, Ansible inventories, Git deployment keys, SFTP/rsync configurations, monitoring probes, backup software, and bastion host settings to use the new port and key.
# Example ~/.ssh/config entry
Host SERVER_ALIAS
HostName SERVER_IP
User ADMIN_USER
Port NEW_PORT
IdentityFile ~/.ssh/SERVER_ALIAS_ed25519
IdentitiesOnly yes
PreferredAuthentications publickeyTypical failure evidence chain
“Connection timed out” usually means the network path is dropping packets; verify listening, host firewall, cloud security group, routing, and NAT. “Connection refused” means the host is reachable but no process is listening. “Permission denied (publickey)” indicates the SSH handshake succeeded, so focus on account, key, permissions, and policy.
# Capture packets for debugging (limit to interface and IP)
sudo timeout 30 tcpdump -ni IFACE 'tcp port NEW_PORT and host MGMT_IP'Rollback design
Rollback must always keep at least one entry reachable. Restore the old port’s SELinux and firewall allowances, revert the sshd configuration, run a syntax check, reload, and verify port 22 is listening before removing the new rules.
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="BACKUP_DIR"
ZONE="ZONE"
# Restore original config files
cp -a "${BACKUP_DIR}/sshd_config" /etc/ssh/sshd_config
if [[ -d "${BACKUP_DIR}/sshd_config.d" ]]; then
cp -a "${BACKUP_DIR}/sshd_config.d/." /etc/ssh/sshd_config.d/
fi
sshd -t
systemctl reload sshd
ss -lntp '( sport = :22 )'Continuous audit and detection
Monitor failed logins, unknown sources, configuration drift, and port changes. Example query for the last 24 hours:
sudo journalctl -u sshd --since '-24 hours' --no-pager \
| grep -E 'Accepted publickey|Failed publickey|Failed password|Invalid user' \
| tail -n 500Periodically compare the effective sshd configuration against an approved baseline:
sudo sshd -T | sort > /var/lib/AUDIT_DIR/sshd-effective.current
sudo diff -u /var/lib/AUDIT_DIR/sshd-effective.approved /var/lib/AUDIT_DIR/sshd-effective.currentAfter package upgrades, re‑run checks for ciphers, MACs, KEX algorithms, and host‑key algorithms to ensure compliance with organizational baselines.
rpm -q openssh openssh-server openssl
sudo sshd -t
sudo sshd -T | grep -E '^(ciphers|macs|kexalgorithms|hostkeyalgorithms) '
ssh -Q cipher
ssh -Q kexFurther tighten key and account boundaries
Maintain a ledger of key issuance, purpose, fingerprint, creation, and revocation dates. Prefer one‑person‑one‑account via the ssh-admins group; revoke a compromised key without affecting other admins. Review authorized_keys for duplicate entries and restrict options such as restrict (requires recent OpenSSH) to disable forwarding and agent usage.
# List key types and fingerprints
sudo awk 'NF>=2 && $1 !~ /^#/ {print $1, $2}' /home/ADMIN_USER/.ssh/authorized_keys \
| while read -r type key; do
printf '%s ' "$type"
printf '%s %s
' "$type" "$key" | ssh-keygen -lf -
doneFor certificate‑based authentication, configure a trusted CA:
TrustedUserCAKeys /etc/ssh/user_ca.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%uManage the CA private key securely, limit issuance, and test revocation. Verify with sshd -t and sshd -T -C before deployment.
IPv4, IPv6 and multi‑interface traps
If only IPv4 is restricted, sshd may still be reachable over IPv6. Check all listening families and addresses:
sudo ss -lntp | grep sshd
ip -6 address show scope global
sudo firewall-cmd --zone=ZONE --list-allIf IPv6 is not used, create explicit IPv6 firewall rules or limit sshd with AddressFamily ipv4 or ListenAddress directives. On multi‑NIC hosts, ensure the management interface belongs to the correct firewalld zone; a mismatched zone results in rules that appear present but are ineffective.
# Example ListenAddress limiting exposure
ListenAddress MANAGEMENT_IFACE_IP:NEW_PORTDo not bind to DHCP‑assigned or floating VIP addresses without confirming they are present; otherwise sshd may fail to start or listen on an unexpected interface.
Audit and continuous detection (summary)
Regularly query recent sshd logs, compare effective configuration to a stored baseline, and re‑run algorithm checks after package updates. Ensure any deviation triggers a review rather than an automatic overwrite of the approved baseline.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Ops Community
A leading IT operations community where professionals share and grow together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
