How to check failed SSH login attempts on Linux

Someone is guessing your root password right now

Put a server on the public internet and the guessing starts within an hour. Most of it is automated. Most of it will never succeed.

That does not make it worthless. Failed logins tell you which accounts are being probed, whether anyone got in, and whether password auth is still enabled when you thought you had turned it off.

Here is how to read them, and how to keep reading them once there is more than one server.

The quick answer

On a systemd machine:

journalctl -u ssh --since "24 hours ago" | grep "Failed password"

On RHEL, Fedora, Rocky and Alma the unit is named sshd instead:

journalctl -u sshd --since "24 hours ago" | grep "Failed password"

No systemd? sshd writes through syslog to a file:

Distribution File
Debian, Ubuntu /var/log/auth.log
RHEL, Fedora, Rocky, Alma /var/log/secure
Alpine /var/log/messages
grep "Failed password" /var/log/auth.log

Use lastb for a summary

lastb prints failed logins the way last prints successful ones. It reads /var/log/btmp and it needs root:

sudo lastb -n 20

Two warnings.

btmp does not exist on every distribution. Debian and Ubuntu create it; several minimal images do not, and lastb then reports btmp: No such file or directory. Create it with the right ownership and logins start being recorded:

sudo touch /var/log/btmp
sudo chown root:utmp /var/log/btmp
sudo chmod 600 /var/log/btmp

And lastb only records attempts that reached PAM. A connection rejected earlier — a bad key against a key-only server — never appears. The journal shows those. Use both.

What sshd actually writes

Every check below matches one of these strings:

Failed password for root from 192.0.2.55 port 33460 ssh2
Failed password for invalid user admin from 192.0.2.55 port 33456 ssh2
Invalid user admin from 192.0.2.55 port 33456
Connection closed by authenticating user root 192.0.2.55 port 33470 [preauth]
Accepted publickey for deploy from 203.0.113.7 port 51234 ssh2

Failed password is a wrong password for an account that exists. invalid user means the account does not exist at all — that is a bot working through a word list. Connection closed by authenticating user is usually a client that ran out of keys to offer.

Accepted is the line that matters most. Read it every time you read the failures.

Count the attempts by source

Raw lines are hard to judge. Counts are not. This groups a day of failures by IP:

journalctl -u ssh --since "24 hours ago" \
  | grep "Failed password" \
  | awk '{for(i=1;i<=NF;i++) if($i=="from") print $(i+1)}' \
  | sort | uniq -c | sort -rn | head

And by account:

journalctl -u ssh --since "24 hours ago" \
  | grep "Failed password" \
  | sed 's/.*for \(invalid user \)\?//' | awk '{print $1}' \
  | sort | uniq -c | sort -rn | head

Thousands of attempts against root, admin and test from scattered addresses is background noise. Twenty attempts against a real username you use is worth a look.

The problem with all of the above

The logs live on the machine being attacked.

That is fine while the attack is failing. It stops being fine the moment one succeeds, because the first thing a competent intruder does is edit the file that proves it. journalctl --vacuum-time=1s takes under a second.

It is also awkward at three servers and impossible at thirty. You cannot grep a fleet.

Both problems have one fix. Copy the lines off the machine as they are written.

Ship the journal, then query it

Install the CL Agent and point it at your log server:

URL = "https://logs.example.com/api/v1/ingest_logs/YOUR-SOURCE-TOKEN"

Put that in /etc/clagent.toml and start the agent. The full walkthrough is in sending journalctl logs to a central server.

The agent runs journalctl -o json, so each line arrives as a journal entry object. sshd lines carry the fields the queries need:

Field Value on an sshd line
SYSLOG_IDENTIFIER sshd
_SYSTEMD_UNIT ssh.service on Debian and Ubuntu, sshd.service on RHEL and Fedora
MESSAGE The text sshd wrote
_HOSTNAME Which machine
PRIORITY Syslog level, as a string

Now the same question, asked once across every server:

SELECT
  json_extract(msg, '$._HOSTNAME') AS host,
  json_extract(msg, '$.MESSAGE')   AS message,
  timestamp
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'sshd'
  AND json_extract(msg, '$.MESSAGE') LIKE 'Failed password%'
ORDER BY timestamp DESC
LIMIT 100;

Keep json_valid(msg) first. One plain-text line in the source — anything not shipped by the agent — makes json_extract raise malformed JSON and abandon the query. In a WHERE clause SQLite short-circuits AND, so the guard protects the terms after it.

It does not protect the SELECT list. Those expressions run against every row the WHERE admits, which is why the guard has to eliminate the bad rows rather than just sit in front of them.

Count by IP in SQL

SQLite has no regex. Pull the address out with string functions instead:

WITH failures AS (
  SELECT substr(json_extract(msg, '$.MESSAGE'),
                instr(json_extract(msg, '$.MESSAGE'), ' from ') + 6) AS tail
  FROM logs
  WHERE json_valid(msg)
    AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'sshd'
    AND json_extract(msg, '$.MESSAGE') LIKE 'Failed password%'
)
SELECT substr(tail, 1, instr(tail, ' ') - 1) AS ip,
       COUNT(*) AS attempts
FROM failures
GROUP BY ip
ORDER BY attempts DESC
LIMIT 20;

instr(..., ' from ') + 6 skips past the separator. The outer substr cuts at the next space, which drops port 33460 ssh2.

Check the successes in the same window while you are there:

SELECT json_extract(msg, '$._HOSTNAME') AS host,
       json_extract(msg, '$.MESSAGE')   AS message,
       timestamp
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'sshd'
  AND json_extract(msg, '$.MESSAGE') LIKE 'Accepted %'
ORDER BY timestamp DESC
LIMIT 50;

A machine that only ever shows Accepted publickey is configured the way you meant. Accepted password on that machine means PasswordAuthentication is still on somewhere.

One caveat about time

The timestamp column is when the server received the line, not when sshd wrote it. After a network outage a backlog arrives at once and sorts as though it just happened.

For event order, use the journal’s own clock:

SELECT datetime(CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000,
                'unixepoch') AS happened_at,
       json_extract(msg, '$.MESSAGE') AS message
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'sshd'
  AND json_extract(msg, '$.MESSAGE') LIKE 'Failed password%'
ORDER BY happened_at DESC
LIMIT 50;

The CAST is required. __REALTIME_TIMESTAMP arrives as a string, and dividing a string by 1000000 does not do what you want.

Stop checking and get told instead

Checking failed logins by hand works until you forget for a week.

An alert rule fires when its query returns any row at all. Only emptiness is measured, so SELECT 1 ... LIMIT 1 is the right shape — selecting columns is wasted work.

This one fires when a single source produces 50 failures in ten minutes:

SELECT 1 WHERE (
  SELECT COUNT(*) FROM logs
  WHERE json_valid(msg)
    AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'sshd'
    AND json_extract(msg, '$.MESSAGE') LIKE 'Failed password%'
    AND timestamp > CAST(strftime('%s', 'now', '-10 minutes') AS INTEGER)
) >= 50 LIMIT 1;

Write the threshold as a scalar subquery, not HAVING. SELECT 1 FROM logs WHERE ... HAVING COUNT(*) >= 50 raises HAVING clause on a non-aggregate query, and in an alert rule that error is invisible — the rule simply never fires.

Rules are evaluated every five minutes and the notification goes out within a minute of that. Set Max Frequency to something longer than the burst you are watching for, or a botnet will page you every five minutes for an hour.

Alerting on SSH logins covers the other half of this: telling you when a login succeeds, which is the rarer and more interesting event.

Reduce the noise at the source

Reading failures is easier when there are fewer of them.

Turn off password authentication. In /etc/ssh/sshd_config:

PasswordAuthentication no
PermitRootLogin no

Then sudo systemctl restart ssh. Confirm you can still get in from a second terminal before you close the first.

That converts most of the noise from Failed password to Connection closed by authenticating user ... [preauth], and it means a guessed password is no longer a way in.

Fail2ban or sshd’s own MaxAuthTries will cut the volume further. Neither replaces reading the log, because neither tells you when something got through.

Where to go next

💌 Get notified on new features and updates

Only sent when a new version is released. Nothing else.