# i get a phone ping every time someone ssh's into my server

> How I get a push notification on my phone whenever someone logs in to my server over SSH, using a PAM hook, a small bash script and ntfy.

By Harshal Ranjhani · [HTML version](https://harshalranjhani.in/blog/i-get-a-phone-ping-every-time-someone-sshs-into-my-server)

I wanted my phone to buzz whenever someone logs in to my server over SSH. Not a daily log summary or an email I read tomorrow, just a push notification that says who logged in and from where.

The setup is small. Every time an SSH session opens, Linux runs it through PAM, the login framework, and PAM can run a script. That script sends a message to [ntfy](https://ntfy.sh), which pushes it to my phone. My own logins come in over [Tailscale without forwarding port 22](https://harshalranjhani.in/blog/ssh-home-server-with-tailscale), so the script skips those. Only logins from somewhere else send a notification.

![Illustration with placeholder data, not a real login. A phone lock screen with an ntfy notification titled "ssh login on my-server" and the text "harshal from 203.0.113.42". 203.0.113.42 is a documentation address.](https://assets.harshalranjhani.in/images/blog/i-get-a-phone-ping-every-time-someone-sshs-into-my-server/ssh-login-alert-phone.png)

This is a tripwire, not a security control. I'll cover what it misses at the end.

## how it works

Login is key only on this server. `PasswordAuthentication` and `KbdInteractiveAuthentication` are `no`, and `PubkeyAuthentication` is `yes`.

The part that matters is `UsePAM yes`. According to the [sshd_config(5) man page](https://man.openbsd.org/sshd_config#UsePAM), it makes sshd run the PAM account and session modules for every kind of login, public key included. Upstream OpenSSH defaults `UsePAM` to `no`, but [Debian's packaged sshd_config](https://sources.debian.org/src/openssh/1%3A10.2p1-5/sshd_config/) sets it to `yes`. Check yours before going further.

![Five boxes in a row: ssh with key auth, sshd with UsePAM yes, pam_exec on session open, the ssh-notify script filtering and sending, and ntfy pushing to the phone. A note under the row says the script is optional and has a 5 second timeout, so a dead ntfy server never blocks a login.](https://assets.harshalranjhani.in/images/blog/i-get-a-phone-ping-every-time-someone-sshs-into-my-server/ssh-login-alert-flow.png)

When a session opens, the `pam_exec` module runs `/usr/local/bin/ssh-notify`. The [pam_exec man page](https://man7.org/linux/man-pages/man8/pam_exec.8.html) lists the environment variables it gives the script. I use three of them:

- `PAM_TYPE` is `open_session` or `close_session`
- `PAM_USER` is the user who logged in
- `PAM_RHOST` is the address they came from

## what you need

- A Linux server running OpenSSH, and root access to it
- The ntfy app on your phone, subscribed to a topic
- `curl` on the server

## step 1: store the ntfy token

The script runs as root, so the token file can be locked down to root only. I keep the token in its own file so it stays out of the script and out of shell history.

```bash
sudo install -d -m 700 -o root -g root /etc/ssh-notify
sudo install -m 600 -o root -g root /dev/null /etc/ssh-notify/ntfy.token
sudoedit /etc/ssh-notify/ntfy.token
```

Put the token on a single line. The token and the `your-topic` name in the script below are placeholders for your own.

## step 2: write the script

Save this as `/usr/local/bin/ssh-notify` and make it executable with `sudo chmod 755 /usr/local/bin/ssh-notify`.

```bash
#!/bin/bash
# /usr/local/bin/ssh-notify

log() { logger -t ssh-notify -- "$*"; }

if [[ "${PAM_TYPE:-}" != "open_session" ]]; then
  log "skip session close, not an open"
  exit 0
fi

user="${PAM_USER:-unknown}"
ip="${PAM_RHOST:-unknown}"
user="${user//[$'\r\n']/}"
ip="${ip//[$'\r\n']/}"
host="$(hostname -s 2>/dev/null || hostname)"

# Tailscale: 100.64.0.0/10, and fd7a:115c:a1e0::/48.
if [[ "$ip" =~ ^100\.([0-9]+)\. ]]; then
  second="${BASH_REMATCH[1]}"
  if (( 10#$second >= 64 && 10#$second <= 127 )); then
    log "skip ${user} from ${ip} (tailnet)"
    exit 0
  fi
fi
if [[ "${ip,,}" == fd7a:115c:a1e0:* ]]; then
  log "skip ${user} from ${ip} (tailnet)"
  exit 0
fi

token_file=/etc/ssh-notify/ntfy.token
topic=your-topic
if [[ ! -r "$token_file" ]]; then
  log "fail ${user} from ${ip} -> ntfy token unreadable"
  exit 1
fi
token="$(<"$token_file")"
token="${token//$'\n'/}"

body="${user} from ${ip}
user ${user}, session open"

http_code="$(
  curl --silent --show-error --output /dev/null \
    --max-time 5 \
    --write-out '%{http_code}' \
    -H "Title: ssh login on ${host}" \
    -H "Priority: high" \
    -H "Content-Type: text/plain" \
    -H "Authorization: Bearer ${token}" \
    --data-binary "$body" \
    "https://ntfy.sh/${topic}"
)"
status=$?

if (( status == 28 )); then
  log "fail ${user} from ${ip} -> ntfy timeout (5s)"
  exit 1
fi
if (( status != 0 )) || [[ "$http_code" != 2* ]]; then
  log "fail ${user} from ${ip} -> ntfy ${http_code:-curl ${status}}"
  exit 1
fi

log "sent ${user} from ${ip} -> ntfy ${http_code}"
exit 0
```

Here's what the script does, top to bottom:

1. It exits early on `close_session`, so you get one ping per login and not two.
2. It reads the user and the address, and strips newlines from both.
3. It skips Tailscale addresses. The ranges are `100.64.0.0/10` and `fd7a:115c:a1e0::/48`, from [Tailscale's reserved IP address docs](https://tailscale.com/docs/reference/reserved-ip-addresses).
4. It sends the message to ntfy. The [publish API](https://docs.ntfy.sh/publish/#authentication) takes the token as a `Bearer` header, [`Title`](https://docs.ntfy.sh/publish/#message-title) replaces the topic name on the lock screen, and [`Priority: high`](https://docs.ntfy.sh/publish/#message-priority) makes it pop over other notifications.
5. It logs the result with `logger -t ssh-notify`, so you can read it back with `journalctl -t ssh-notify`.

I used `--max-time 5` on curl so a slow or dead ntfy server can't hold the login for long. Exit code 28 is curl's timeout. I could have sent curl to the background, but then the log line would be written before the result is known. I'd rather wait the five seconds in the worst case.

## step 3: hook it into PAM

Add this as the last line of `/etc/pam.d/sshd`:

```text
session optional pam_exec.so quiet /usr/local/bin/ssh-notify
```

Two words in that line matter.

`optional` is the important one. According to the [pam.conf man page](https://man7.org/linux/man-pages/man5/pam.conf.5.html), an optional module's result is ignored unless it's the only module in the stack. If I had used `required` and the script failed, the login could fail with it. Current OpenSSH [restricts the session](https://github.com/openssh/openssh-portable/blob/master/auth-pam.c) when PAM fails to open it, and the shell [exits with 254](https://github.com/openssh/openssh-portable/blob/master/session.c). A dead notifier that locks me out of my own server would be worse than no notifier.

`quiet` keeps the script's exit status out of the login output.

PAM files are read on the next login, so there's no need to reload sshd for this line. Keep a second SSH session open while you test anyway, in case you made a typo.

## step 4: test it

Log in from a device that isn't on your tailnet, then check the log:

```bash
journalctl -t ssh-notify
```

You should see a `sent` line with `ntfy 200`, and a notification on your phone. A login over Tailscale should show a `skip` line and nothing on the phone.

![Illustration, not a captured log. 203.0.113.42 and 198.51.100.9 are documentation addresses. The output of journalctl -t ssh-notify shows a skipped tailnet login, a sent notification with ntfy 200, a skipped session close, and a 5 second timeout that did not stop the login.](https://assets.harshalranjhani.in/images/blog/i-get-a-phone-ping-every-time-someone-sshs-into-my-server/ssh-login-alert-journal.png)

## what it misses

**A public ntfy.sh topic is readable by anyone who guesses the name.** The [ntfy docs](https://docs.ntfy.sh/publish/#picking-a-topic) say the topic name is effectively the password when you don't sign up, and [topics there are public](https://docs.ntfy.sh/publish/#public-topics). A bearer token looks like authentication, but on ntfy.sh it doesn't give you privacy. Keep the message free of anything you wouldn't want on a lock screen. I only send the user and the address.

**It pings once per connection, not once per shell.** OpenSSH calls [`do_pam_session`](https://github.com/openssh/openssh-portable/blob/master/sshd-session.c) after authentication and before any channel request. So `sftp` and `ssh -N` ping too, and the script can't tell them apart from a shell. It's noisier than I wanted.

**Failed logins never reach it.** A rejected key never opens a session. Something like [fail2ban](https://github.com/fail2ban/fail2ban) is the tool that sees those.

**Root can remove the line.** I don't have a fix for this one. Anyone who is already root can edit `/etc/pam.d/sshd`, so this only notices people who aren't.

**The Tailscale skip is address based.** `100.64.0.0/10` is shared CGNAT space, so an address in that range isn't proof the login came from my tailnet. Also, if you turn on [`UseDNS`](https://man.openbsd.org/sshd_config#UseDNS), sshd may pass a hostname instead of an address and the prefix check will miss. It defaults to `no`, so leave it off.

## fix the public topic: self-host ntfy or use pushover

The weak spot in this setup is the public ntfy.sh topic, since anyone who guesses the name can read it. There are two ways to fix that.

The first is to run your own ntfy server and set [`auth-default-access: deny-all`](https://docs.ntfy.sh/config/#access-control) in its config. Then the token is real access control, and nothing goes through a public server.

The second is [Pushover](https://pushover.net), a similar notification service, and it's what I use. It works with accounts and per-app tokens instead of public topics. To switch, replace the `curl` call in the script with a POST to the [Pushover message API](https://pushover.net/api). It needs an app token, your user key and a message, and `priority=1` is the equivalent of `Priority: high`:

```bash
curl --silent --show-error --output /dev/null \
  --max-time 5 \
  --write-out '%{http_code}' \
  --form-string "token=${app_token}" \
  --form-string "user=${user_key}" \
  --form-string "title=ssh login on ${host}" \
  --form-string "message=${body}" \
  --form-string "priority=1" \
  https://api.pushover.net/1/messages.json
```

The app token and user key are both secrets, so keep them in a root-only file like the ntfy token in step 1. The rest of the script stays the same. Pushover's API returns a 200 when it accepts the message, so the `2*` check on the status code still works.

Last updated on October 28, 2026
