Audit SSH Config
Problem statement
Check an SSH server config for risky settings, the way sshd itself reads it, and check that the SSH key files have safe permissions. SSH is the front door of almost every Linux server, so "is root login on?", "are passwords allowed?" and "are the keys locked down?" come up in every hardening review.
sshd_config
# Example sshd_configPort 22#PermitRootLogin noPermitRootLogin yesPasswordAuthentication yesPasswordAuthentication noPubkeyAuthentication yesX11Forwarding yes Match User backup PermitEmptyPasswords yes.ssh folder
.ssh/id_ed25519 600 private key.ssh/id_ed25519.pub 644 public key.ssh/id_rsa 644 private key.ssh/authorized_keys 664 allowed public keysDo two checks:
- Work out the value
sshdreally uses forPermitRootLogin,PasswordAuthenticationandPermitEmptyPasswords, and markyesasRISKY. If a setting is not in the file, use the default and say so. - Report each private key as OK if its mode is
600or400, and reportauthorized_keysas BAD if the group or others can write to it.
Expected output:
== effective settings (first match wins) ==permitrootlogin yes RISKYpasswordauthentication yes RISKYpermitemptypasswords no ok (default)== key file checks ==OK 600 .ssh/id_ed25519BAD 644 .ssh/id_rsa (private key should be 600)BAD 664 .ssh/authorized_keys (group or others can write)Hints
Approach
Optimal: awk first match, stat checks
Covers: how sshd_config is read, awk with tolower, exit and END, stat -c '%a', find -perm /022, [[ ]] tests, glob loops.
How sshd reads its config. The rules are short, and they surprise many people:
in any case"]):::blue K --> F(["keep the first value,
ignore later ones"]):::green F --> M(["stop at Match:
per-user rules"]):::yellow classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
- Lines starting with
#and empty lines are skipped.#PermitRootLogin nodoes nothing. - Keywords are not case-sensitive:
PermitRootLogin,permitrootloginandPERMITROOTLOGINare the same. - For most settings, the first value wins. In the sample,
PasswordAuthentication yescomes first, so the laternois ignored. Many people add anoat the bottom and think they are safe. - A
Matchline starts a block that applies only to the users, groups or addresses it names. Settings above the firstMatchare the global ones. - A setting that is missing uses its built-in default.
What the three settings mean.
| Setting | Risky value | Why | Default |
|---|---|---|---|
PermitRootLogin |
yes |
anyone who guesses or steals the root password gets full control | prohibit-password (root only with a key) |
PasswordAuthentication |
yes |
passwords can be guessed by bots trying thousands per hour | yes |
PermitEmptyPasswords |
yes |
an account with no password can log in with nothing | no |
Notice that PasswordAuthentication defaults to yes. A config that says nothing about it still allows passwords.
Key file permissions. SSH protects you from yourself here:
600"]:::green --> O1(["ssh uses it"]):::green end subgraph NO["Rejected or unsafe"] direction TB K2["id_rsa
644"]:::red --> O2(["ssh refuses it"]):::red A1["authorized_keys
664"]:::red --> O3(["others could
add a key"]):::red end OK ~~~ NO classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style OK fill:transparent,stroke:#059669,stroke-width:2px style NO fill:transparent,stroke:#dc2626,stroke-width:2px
- A private key (
id_ed25519,id_rsa) must be readable only by you:600or400. If the group or others can read it,sshrefuses to use it. authorized_keyslists the public keys allowed to log in as you. If someone else can write to it, they can add their own key and log in as you. sshd checks this too, and can refuse logins when the file or the.sshfolder is writable by others.- Public keys (
.pub) are meant to be shared, so644is fine.
Walking through the code. The # Setup: lines only create the sample config and key files, so skip past them.
- The
awkprogram reads the config line by line./^[[:space:]]*#/ || NF == 0 { next }skips comments (even indented ones) and empty lines.tolower($1) == "match" { exit }stops at the first Match block. In awk,exitjumps straight to theENDblock, so the report still prints.if (!(k in seen)) seen[k] = ...stores a keyword's value only the first time, which copies sshd's "first value wins" rule.- In
END, a short list holds each setting and its default. For each, the code prints the value it found, or the default with a(default)note, and marksyesasRISKY.
- The
for f in .ssh/id_*loop visits every key file.[[ $f == *.pub ]] && continueskips public keys.stat -c '%a'reads the mode, and[[ ]]compares it with600and400. - For
authorized_keys,find "$f" -perm /022prints the file only if group write (020) or others write (002) is on.[[ -n ... ]]is true when that output is not empty.
Edge cases. sshd also accepts Keyword=value and tabs between the two parts, which this simple script does not handle; the real tool below does. An Include line pulls in more files, often from /etc/ssh/sshd_config.d/, and those are read in place, so a file there can set a value first.
# Setup: a sample sshd_config and a sample .ssh folder in a fresh temporary folder
cd "$(mktemp -d)"
cat > sshd_config << 'CONF'
# Example sshd_config
Port 22
#PermitRootLogin no
PermitRootLogin yes
PasswordAuthentication yes
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding yes
Match User backup
PermitEmptyPasswords yes
CONF
mkdir .ssh
printf 'FAKE KEY\n' > .ssh/id_ed25519; chmod 600 .ssh/id_ed25519
printf 'FAKE PUB\n' > .ssh/id_ed25519.pub; chmod 644 .ssh/id_ed25519.pub
printf 'FAKE KEY\n' > .ssh/id_rsa; chmod 644 .ssh/id_rsa
printf 'ssh-ed25519 AAAA test\n' > .ssh/authorized_keys; chmod 664 .ssh/authorized_keys
# 1. Work out the settings sshd really uses: comments skipped, keys in any case,
# the FIRST value wins, and we stop at the first Match block (it only applies to some users).
echo "== effective settings (first match wins) =="
awk '
/^[[:space:]]*#/ || NF == 0 { next }
tolower($1) == "match" { exit }
{ k = tolower($1); if (!(k in seen)) seen[k] = tolower($2) }
END {
split("permitrootlogin prohibit-password passwordauthentication yes permitemptypasswords no", d, " ")
for (i = 1; i <= 6; i += 2) {
key = d[i]; val = (key in seen) ? seen[key] : d[i+1]
note = (key in seen) ? "" : " (default)"
print key, val, (val == "yes" ? "RISKY" : "ok") note
}
}' sshd_config
# 2. Private keys must be readable only by you; authorized_keys must not be writable by others.
echo "== key file checks =="
for f in .ssh/id_*; do
[[ $f == *.pub ]] && continue
mode=$(stat -c '%a' "$f")
if [[ $mode == 600 || $mode == 400 ]]; then
echo "OK $mode $f"
else
echo "BAD $mode $f (private key should be 600)"
fi
done
f=.ssh/authorized_keys
mode=$(stat -c '%a' "$f")
if [[ -n $(find "$f" -perm /022) ]]; then
echo "BAD $mode $f (group or others can write)"
else
echo "OK $mode $f"
fiInterview follow-ups
Also flag a .ssh folder that is writable by group or others.
sshd's
StrictModessetting, which is on by default, can refuse key logins when the user's home folder,~/.sshorauthorized_keyscan be written by others. Add a check on the folder itself:find .ssh -maxdepth 0 -perm /022prints.sshonly if the group or others can write to it. The safe modes are700for.sshand600forauthorized_keys. For a whole server, loop over each home folder fromgetent passwdand run the same checks for every user.
Frequently asked questions
Run sudo sshd -T. It reads the main file, every Include file and the defaults, and prints the final value of every setting, one per line, in lowercase. That is the source of truth, and sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication)' answers the audit in one line. To see what applies to one user, add the connection details: sudo sshd -T -C user=backup,host=x,addr=10.0.0.5. Parsing the file yourself, as on this page, is still useful when you only have a copy of the config.
First check the file for errors with sudo sshd -t; it prints nothing if the config is valid. Then reload the service: sudo systemctl reload ssh (the unit is sshd on RHEL-style systems). Reloading keeps your current session open. Before you close it, open a second terminal and test a fresh login. If the new login fails, you still have the old session to fix the mistake.
Because sshd keeps the first value it finds for most keywords, and ignores later ones. Cloud images and installers often add settings near the top or in an Include file, so your line at the bottom loses. The fix is to put your setting in a file that is read first, such as /etc/ssh/sshd_config.d/00-hardening.conf on systems that include that folder at the top, or to edit the existing line. Always confirm with sshd -T afterwards.