A fresh VPS starts receiving SSH brute-force attempts within minutes of
going public. Most of us find out about a breach after the damage -
a spike in outbound traffic, a suspended hosting account, a client asking
why their site is serving spam.
What if you just... knew? The second someone authenticates?
Here's a complete, working setup that sends a Telegram message to your
phone every time a user successfully logs in over SSH - key or password,
interactive or automated. It's about 30 lines of Bash plus one line of PAM
config. No agent, no daemon, no third-party service beyond Telegram's free
Bot API.
How it works
SSH login events pass through PAM (Pluggable Authentication Modules)
on Linux. PAM has a module called pam_exec that can run an arbitrary
script at specific points in the auth lifecycle - including
open_session, which fires only after a successful login.
/etc/pam.d/sshd
└─ pam_exec (open_session) ──▶ /usr/local/bin/ssh-notify
└─ curl → Telegram Bot API
That's the entire architecture. No polling, no log-tailing, no extra
processes running in the background.
Step 1 - Create a Telegram bot (2 minutes)
- Open Telegram, search for @botfather
- Send
/newbot, follow the prompts, name it whatever you like - Copy the token it gives you - looks like
123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
Now get your chat ID:
- Send any message to your new bot
- Visit
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdatesin a browser - Find
"chat":{"id":YOUR_CHAT_ID}in the JSON response
That's it - no app registration, no OAuth flow, no approval process.
Step 2 - Store the credentials securely
sudo mkdir -p /etc/serversecure
sudo tee /etc/serversecure/telegram.conf > /dev/null <<'EOF'
BOT_TOKEN="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
CHAT_ID="987654321"
EOF
sudo chmod 600 /etc/serversecure/telegram.conf
sudo chown root:root /etc/serversecure/telegram.conf
Mode 600, owned by root - the bot token is effectively a password.
Never commit this file to version control.
Step 3 - The notification script
Save this as /usr/local/bin/ssh-notify:
#!/usr/bin/env bash
# ssh-notify - Telegram SSH login alert via pam_exec
#
# CRITICAL: this script MUST exit 0 under all circumstances.
# A non-zero exit code from a pam_exec hook can block the login.
readonly CONF_FILE="/etc/serversecure/telegram.conf"
readonly STATE_DIR="/var/lib/ssh-notify"
main() {
# Only fire on successful login, not on session close
if [[ "${PAM_TYPE:-}" != "open_session" ]]; then
return 0
fi
[[ -f "${CONF_FILE}" ]] || return 0
# shellcheck source=/dev/null
source "${CONF_FILE}"
[[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] || return 0
# --- Rate limiting: collapse repeat alerts within 60s ---
# Tools like VS Code Remote SSH or rsync open multiple sessions per
# connection. Without this, one "login" becomes four notifications.
local user="${PAM_USER:-unknown}"
local rhost="${PAM_RHOST:-unknown}"
local key
key=$(echo "${user}@${rhost}" | tr -c '[:alnum:]@.' '_')
local state_file="${STATE_DIR}/${key}"
if [[ -f "${state_file}" ]]; then
local last now elapsed
last=$(cat "${state_file}" 2>/dev/null || echo 0)
now=$(date +%s)
elapsed=$(( now - last ))
[[ "${elapsed}" -lt 60 ]] && return 0
fi
mkdir -p "${STATE_DIR}" 2>/dev/null
date +%s > "${state_file}" 2>/dev/null
# --- Build the alert ---
local host ip
host=$(hostname)
ip=$(curl -sf --max-time 3 https://ifconfig.me 2>/dev/null || echo "unknown")
local message="🔐 *SSH Login Alert - ${host}*
🖥️ Server: \`${host}\` (\`${ip}\`)
👤 User: \`${user}\`
🌐 Source IP: \`${rhost}\`
🕒 Time: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
# --- Send it async, hard-timeout, never block the login ---
(
setsid curl -sf --max-time 5 \
-X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
-d "parse_mode=Markdown" \
--data-urlencode "text=${message}" \
>/dev/null 2>&1
) &
return 0
}
main
exit 0
sudo chmod 755 /usr/local/bin/ssh-notify
sudo chown root:root /usr/local/bin/ssh-notify
Three design decisions worth calling out, because they're the difference
between "cool script" and "thing you can trust in production":
- It always exits 0. A pam_exec hook that returns non-zero can be configured to block the login it's supposed to be reporting on. This script fails silently, always, no exceptions.
-
The curl call runs in a detached subshell (
setsid ... &). If Telegram's API is slow or your network hiccups, your SSH login is never delayed waiting on it. - Rate limiting by IP+user. Without this, tools that open multiple SSH channels per "connection" (VS Code Remote is the worst offender) will spam you with 3-4 notifications for what is, to you, one login.
Step 4 - Wire it into PAM
echo "session optional pam_exec.so seteuid /usr/local/bin/ssh-notify" | \
sudo tee -a /etc/pam.d/sshd
The optional control flag is important - it tells PAM this module's
success or failure has no bearing on whether the login proceeds.
Restart nothing - PAM config is read per-session, so your next SSH login
will trigger it. Test with a login from another terminal window; you
should get a Telegram message within a couple of seconds.
What this doesn't cover (and what would)
This gets you real-time visibility. It doesn't get you:
- Geolocation on the source IP - doable with a free IP lookup API, left out here to avoid a third-party dependency by default (and for privacy - you're now sending login IPs to another service).
- "New device" flagging - tracking previously-seen IPs and flagging first-time sources needs a small persistent IP list, easy to add.
-
Fail2Ban ban alerts, sudo usage alerts, disk-full alerts - same
pattern, different trigger. PAM's
pam_execonly covers auth events; these need to hook intosudo's own PAM stack or a cron check.
If you want all of that plus the rest of the hardening that should happen
before you ever expose SSH to the internet - non-root user setup, UFW,
Fail2Ban jails, automatic SSL - that's the larger tool this is one module
of: ServerSecure Setup. This snippet is the full
signature feature, free, because it costs me nothing to give away and it's
the best proof I can offer that the rest of the tool is built with the
same care.
Questions or found an edge case? Drop a comment - I read and reply to
every one.
Top comments (0)