Real-time SSH login alerting via email, triggered directly from ~/.bash_profile. Sends an immediate email on every SSH login, with the connecting IP address, username, authentication method, and timestamp.
No daemons, no dependencies beyond the Python standard library, no root access required. Works on shared hosting.
Two components work together:
1. A one-liner in ~/.bash_profile writes a record to ~/.ssh_logins on every login, then immediately calls the monitor script:
_ssh_auth="unknown"; [ -n "$SSH_USER_AUTH" ] && _ssh_auth=$(awk 'NR==1{print $1}' "$SSH_USER_AUTH" 2>/dev/null); [ -z "$_ssh_auth" ] && _ssh_auth="unknown"
echo "$(date '+%Y-%m-%d %H:%M:%S %z') ${SSH_CLIENT%% *} $USER $_ssh_auth" >> ~/.ssh_logins
/usr/bin/python3 ~/bin/ssh_login_monitor.py$SSH_CLIENTis set by the SSH daemon and contains the client IP, source port, and destination port.${SSH_CLIENT%% *}strips everything after the first space, leaving just the IP.$USERis the authenticated username.$SSH_USER_AUTHis a path to a temporary file set by OpenSSH whenExposeAuthInfo yesis configured insshd_config. It contains the authentication method (e.g.publickey). On systems where this is unavailable it will fall back tounknown.
2. ssh_login_monitor.py compares the current line count of ~/.ssh_logins against a stored state file. Any new lines trigger an individual email alert.
Because the script is called directly by the login event, alerts are sent immediately rather than waiting for a cron poll.
2026-04-13 16:46:59 +0100 1.2.3.4 alice publickey
New SSH login detected on <HOST>.
IP address : 1.2.3.4
Username : alice
Auth method : publickey
Login time : 2026-04-13 16:46:59 +0100
If this was not you, take action immediately.
- Python 3.6+
- A mail sending method: either local
sendmail(available on most Linux/cPanel hosts) or an SMTP server - SSH access to your server with a shell that sources
~/.bash_profile - OpenSSH 7.8+ with
ExposeAuthInfo yesinsshd_configfor authentication method detection (older versions or systems without this setting will logunknown)
To populate the authentication method field, add the following to /etc/ssh/sshd_config:
ExposeAuthInfo yes
Then restart sshd:
systemctl restart sshdNote: This step requires root access and is not possible on shared hosting. On shared hosting, or any system where
ExposeAuthInfocannot be set, auth method will always showunknown. Everything else functions normally.
Add these lines to ~/.bash_profile:
_ssh_auth=$(awk 'NR==1{print $1}' "$SSH_USER_AUTH" 2>/dev/null); [ -z "$_ssh_auth" ] && _ssh_auth="unknown"
echo "$(date '+%Y-%m-%d %H:%M:%S %z') ${SSH_CLIENT%% *} $USER $_ssh_auth" >> ~/.ssh_logins
/usr/bin/python3 ~/bin/ssh_login_monitor.pyLog out and back in, then verify the log file is being written:
cat ~/.ssh_loginsYou should see a line like:
2026-04-13 16:46:59 +0100 1.2.3.4 alice publickey
Create ~/.ssh_login_monitor.conf. Choose either sendmail or smtp as your delivery method.
Using local sendmail (recommended for shared hosting):
[alert]
to = you@example.com
from = alerts@yourdomain.com
method = sendmailUsing SMTP with STARTTLS (port 587):
[alert]
to = you@example.com
from = alerts@yourdomain.com
method = smtp
[smtp]
host = mail.yourdomain.com
port = 587
user = alerts@yourdomain.com
password = YOUR_PASSWORD_HERE
starttls = yes
ssl = noUsing SMTP with implicit SSL (port 465):
[alert]
to = you@example.com
from = alerts@yourdomain.com
method = smtp
[smtp]
host = mail.yourdomain.com
port = 465
user = alerts@yourdomain.com
password = YOUR_PASSWORD_HERE
ssl = yes
starttls = noSecure the config file:
chmod 600 ~/.ssh_login_monitor.confmkdir -p ~/bin ~/logs
cp ssh_login_monitor.py ~/bin/
chmod 700 ~/bin/ssh_login_monitor.pyCheck the correct path to Python on your system:
which python3Update the path in ~/.bash_profile if it differs from /usr/bin/python3.
Add a cron job as a safety net in case the script fails silently during a login:
* * * * * /usr/bin/python3 ~/bin/ssh_login_monitor.py
The script is idempotent — if no new logins are detected it exits silently.
On first run the script seeds ~/.ssh_login_monitor.state with the current line count of ~/.ssh_logins and exits without sending any alerts. This prevents a flood of alerts for existing login history. The next login after deployment will trigger the first real alert.
Check the log to confirm seeding worked:
cat ~/logs/ssh_login_monitor.logExpected output:
[2026-04-13 16:35:27] First run, seeding state with 3 existing entries
| File | Purpose |
|---|---|
~/.ssh_logins |
Append-only login log written by ~/.bash_profile |
~/.ssh_login_monitor.conf |
Configuration (delivery method, addresses, SMTP credentials) |
~/.ssh_login_monitor.state |
Single integer, line count at last successful alert |
~/logs/ssh_login_monitor.log |
Operational log for debugging |
HOME = Path.home()
CONFIG_FILE = HOME / ".ssh_login_monitor.conf"
STATE_FILE = HOME / ".ssh_login_monitor.state"
LOGINLOG = HOME / ".ssh_logins"
LOG_FILE = HOME / "logs" / "ssh_login_monitor.log"All file paths are anchored to the home directory via Path.home(), so no username is hardcoded. The / operator on Path objects is a clean way to build paths without string concatenation.
def log(msg: str):
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(LOG_FILE, "a") as f:
f.write(f"[{ts}] {msg}\n")Simple append-only logger. Creates ~/logs/ if it doesn't exist yet, stamps every message with a timestamp, and appends it to the log file. Used throughout the script for both successful actions and errors.
def load_config() -> configparser.ConfigParser:
if not CONFIG_FILE.exists():
log(f"ERROR: Config file not found: {CONFIG_FILE}")
sys.exit(1)
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE)
return cfgReads the .conf file into a ConfigParser object, which allows values to be accessed with cfg["section"]["key"] syntax. Exits immediately with a logged error if the config is missing, rather than crashing with an unhelpful traceback later.
def send_email(cfg, subject, body):Builds a proper MIME email message with correctly formatted headers including Date and Message-ID (derived from the configured from address), then branches on the method setting in the config:
- sendmail: pipes the formatted message directly to
/usr/sbin/sendmailvia stdin. The local mail system handles delivery without authentication. Ideal for shared hosting or any server with a local MTA. - smtp with STARTTLS: opens a plain connection to the SMTP server then upgrades to TLS via
STARTTLS. Use with port 587. - smtp with implicit SSL: opens a TLS-wrapped connection immediately using
smtplib.SMTP_SSL. Use with port 465.
def get_stored_count() -> int:
try:
return int(STATE_FILE.read_text().strip())
except (FileNotFoundError, ValueError):
return -1
def store_count(count: int):
STATE_FILE.write_text(str(count))The state file contains a single integer, the number of lines in ~/.ssh_logins at the time of the last successfully sent alert. -1 is the sentinel value for "state file does not exist yet" (first run). This number is what allows the script to know which lines are new on each execution.
def parse_login_line(line: str) -> dict:
parts = line.strip().split()
return {
"timestamp": f"{parts[0]} {parts[1]} {parts[2]}",
"ip": parts[3],
"username": parts[4] if len(parts) > 4 else "unknown",
"auth_method": parts[5] if len(parts) > 5 else "unknown",
}Splits a line like 2026-04-13 16:46:59 +0100 1.2.3.4 alice publickey into its components by position. Both username and auth_method default to unknown defensively if the fields are absent — for example in log entries written before this feature was added, or on systems where ExposeAuthInfo is unavailable.
lines = [l.strip() for l in LOGINLOG.read_text().splitlines() if l.strip()]
current_count = len(lines)
stored_count = get_stored_count()Reads the entire ~/.ssh_logins file and counts the non-empty lines. Compares against the stored count to determine if anything is new.
if stored_count == -1:
store_count(current_count)
log(f"First run, seeding state with {current_count} existing entries")
returnFirst run only — writes the current line count to the state file and exits without sending any alerts. This prevents alerting on historical logins that predate deployment.
if current_count <= stored_count:
returnNothing new, exit silently. This is the path taken on the vast majority of executions.
new_lines = lines[stored_count:]Slices the list from the last known position to the end. If the stored count was 5 and there are now 7 lines, new_lines contains lines at index 5 and 6 (the 6th and 7th entries). This correctly handles multiple simultaneous logins.
for line in new_lines:
try:
send_email(cfg, subject, body)
log(...)
except Exception as e:
log(...)
errors.append(line)Iterates over every new line and sends an individual email for each one, collecting any failures.
if not errors:
store_count(current_count)
else:
failed_index = lines.index(errors[0])
store_count(failed_index)State only advances as far as the last successfully delivered alert. If sending fails partway through a batch, the next execution retries from the failure point rather than silently skipping undelivered alerts.
MIT