DEV Community

Cover image for Get a Telegram Notification Every Time Someone SSHes Into Your Server
Ardiansyah Sulistyo
Ardiansyah Sulistyo

Posted on

Get a Telegram Notification Every Time Someone SSHes Into Your Server

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
Enter fullscreen mode Exit fullscreen mode

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)

  1. Open Telegram, search for @botfather
  2. Send /newbot, follow the prompts, name it whatever you like
  3. Copy the token it gives you - looks like 123456789:ABCdefGHIjklMNOpqrSTUvwxYZ

Now get your chat ID:

  1. Send any message to your new bot
  2. Visit https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates in a browser
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
sudo chmod 755 /usr/local/bin/ssh-notify
sudo chown root:root /usr/local/bin/ssh-notify
Enter fullscreen mode Exit fullscreen mode

Three design decisions worth calling out, because they're the difference
between "cool script" and "thing you can trust in production":

  1. 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.
  2. 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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_exec only covers auth events; these need to hook into sudo'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)