How to get an alert when someone logs into your server over SSH

Was that you?

Someone signed into your server at 03:14. You would like to know whether it was you.

On a single box you can go and look. last, journalctl -u ssh, done. That stops working the moment there are five boxes, and it stops working entirely if the intruder has root — the logs they would incriminate themselves with are on the machine they now control.

Both problems have the same fix. Get the log lines off the machine as they happen, then alert on the ones that matter.

Step 1: Ship the journal

sshd writes to the systemd journal. Install the CL Agent and the journal goes to your server:

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

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

The agent runs journalctl -o json, so each line arrives as a journal entry object. Auth lines carry the fields you 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

No systemd? sshd writes to /var/log/auth.log or /var/log/secure through syslog instead. Point rsyslog at the server and the same rules work against plain text — see sending rsyslog logs to a central server. The queries below use MESSAGE; on the syslog path, match msg directly.

Step 2: Know what sshd writes

Every rule here keys off one of these strings. They have been stable for years:

Accepted publickey for deploy from 203.0.113.7 port 51234 ssh2: ED25519 SHA256:...
Accepted password for root from 198.51.100.4 port 40122 ssh2
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]
pam_unix(sshd:session): session opened for user deploy(uid=1000) by (uid=0)
Disconnected from user deploy 203.0.113.7 port 51234

Two things fall out of that list.

Accepted means the login succeeded. Failed and Invalid mean it did not. The internet will generate thousands of the second kind and none of the first.

And Accepted publickey is a different event from Accepted password. On a server configured the way you meant to configure it, the second one should never appear.

Step 3: Alert on a successful login

Create an alert rule with alert on results:

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

json_valid(msg) is not decoration. One plain-text line anywhere in the source makes json_extract raise “malformed JSON”, and the rule then fails silently on every run. SQLite short-circuits AND, so the guard has to come first.

The ten-minute window is deliberate. Rules evaluate every five minutes, and a line arriving near the boundary would fall between two runs of a five-minute window.

That rule is the blunt version. Read on before you rely on it.

Step 4: Alert on the logins that deserve it

A rule notifies you at most once per Max Frequency — 5 minutes at the shortest, once a day by default. On a server you log into all day, the daily setting turns “someone logged in” into “the first login today”, which is not much of a security control. Shortening it to 5 minutes turns it into noise you will mute by Thursday.

Neither setting fixes the rule. Do not alert on every login. Alert on the ones that should never happen, and give those 5 minutes so a real one reaches you twice.

Root logged in with a password. If this fires, either your config drifted or someone guessed:

SELECT 1 FROM logs
WHERE timestamp > CAST(strftime('%s','now','-10 minutes') AS INTEGER)
  AND json_valid(msg)
  AND json_extract(msg, '$.MESSAGE') LIKE 'Accepted password for root %'
LIMIT 1

Anyone logged in with a password. On a key-only server, every success should say publickey:

SELECT 1 FROM logs
WHERE timestamp > CAST(strftime('%s','now','-10 minutes') AS INTEGER)
  AND json_valid(msg)
  AND json_extract(msg, '$.MESSAGE') LIKE 'Accepted password %'
LIMIT 1

A login by someone who is not on the list. Name the accounts that are allowed to log in and alert on everything else:

SELECT 1 FROM logs
WHERE timestamp > CAST(strftime('%s','now','-10 minutes') AS INTEGER)
  AND json_valid(msg)
  AND json_extract(msg, '$.MESSAGE') LIKE 'Accepted %'
  AND json_extract(msg, '$.MESSAGE') NOT LIKE 'Accepted publickey for deploy %'
  AND json_extract(msg, '$.MESSAGE') NOT LIKE 'Accepted publickey for ansible %'
LIMIT 1

This one earns its keep. A new account appearing in the accepted list is what a persistent intruder leaves behind.

A brute-force burst. Failures are constant background noise, so count them rather than matching them:

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

Use a scalar subquery, not HAVING. SQLite rejects HAVING without GROUP BY, and the rule would error on every evaluation.

Set the threshold from your own traffic, not from this page. Query the last week first and see what a normal day looks like. On a box with SSH open to the internet, a hundred failures in ten minutes may be Tuesday.

A source that went quiet. Worth pairing with all of the above. Set the condition to alert on empty:

SELECT 1 FROM logs
WHERE timestamp > CAST(strftime('%s','now','-1 hour') AS INTEGER)
LIMIT 1

An attacker who stops the agent stops your alerts. This is the rule that notices.

Step 5: Reconstruct what happened

Alerting tells you to look. These queries are the looking.

Every successful login, newest first. journald stores the real event time in __REALTIME_TIMESTAMP, in microseconds. Use it rather than timestamp, which records when the line reached your server:

SELECT datetime(CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER)/1000000, 'unixepoch') AS at,
       json_extract(msg, '$._HOSTNAME') AS host,
       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 'Accepted %'
ORDER BY at DESC
LIMIT 100

The distinction matters after an outage. A batch of lines buffered during a network blip all arrive at once and sort as if they happened at the same moment. The event time keeps them in order.

Everything sshd said in a window, once you have a time to look at:

SELECT datetime(CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER)/1000000, 'unixepoch') AS at,
       json_extract(msg, '$.MESSAGE') AS message
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'sshd'
  AND timestamp BETWEEN CAST(strftime('%s','2026-08-09 03:00:00') AS INTEGER)
                    AND CAST(strftime('%s','2026-08-09 04:00:00') AS INTEGER)
ORDER BY at

What they did next. A login is only interesting because of what follows it. sudo writes to the journal too:

SELECT datetime(CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER)/1000000, 'unixepoch') AS at,
       json_extract(msg, '$._HOSTNAME') AS host,
       json_extract(msg, '$.MESSAGE') AS message
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'sudo'
  AND json_extract(msg, '$.MESSAGE') LIKE '%COMMAND=%'
ORDER BY at DESC
LIMIT 100

sudo lines look like deploy : TTY=pts/0 ; PWD=/home/deploy ; USER=root ; COMMAND=/bin/bash. That gives you who, from where, and what they ran.

Which addresses are trying hardest:

SELECT json_extract(msg, '$.MESSAGE') AS message, COUNT(*) AS attempts
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.MESSAGE') LIKE 'Failed password%'
  AND timestamp > CAST(strftime('%s','now','-7 days') AS INTEGER)
GROUP BY message
ORDER BY attempts DESC
LIMIT 50

sshd puts the port in the message, so each attempt is its own string and the grouping is coarse. It is still enough to see the shape of an attack.

What this does not replace

Alerting on logins is detection. It is not prevention, and the two are not interchangeable.

Turn off password authentication and root login in /etc/ssh/sshd_config:

PasswordAuthentication no
PermitRootLogin no

That single change removes the entire class of attack the brute-force rule is watching for. Run fail2ban or CrowdSec if SSH faces the internet. Better still, keep SSH off the internet — a WireGuard tunnel or a bastion means the failure counter stays at zero.

Then keep the alerts, because the point of central logging is that they still work when the machine does not. Prevention fails quietly. Detection is what tells you that it did.

Summary

Ship the journal with the CL Agent. sshd writes Accepted, Failed and Invalid, and those three words carry almost everything.

Do not alert on every login — the throttle makes that useless. Alert on the impossible ones: root with a password, any password on a key-only box, a username that is not on your list, and a failure burst above your own baseline. Add an alert-on-empty rule so a silenced agent is itself an alert.

Then harden the server so none of it fires.

Central Logging runs log search, alerting, uptime checks and host monitoring from one binary you run yourself. See Alerting for the reference, alerting on errors in your logs for the rules that are not about SSH, or Deploy to get started.

💌 Get notified on new features and updates

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