Every login to my boxes now announces itself in a private Discord channel. This isn’t for convenience. It’s because I don’t trust last or journalctl as the only record of who logged in and rsyslog forwarding to a separate box has been on the to-do list for a while without actually happening. Both live on the same disk. Both can be deleted by whoever just broke in. I’ve seen it happen. A notification that already left the building before anyone touched the logs is worth more than the cleanest audit trail sitting on the same disk the attacker now owns.
Credit where it’s due: the core script here is basically lifted from a DigitalOcean community answer on triggering webhooks from SSH sessions. I made one small fix and learned one lesson the hard way. Both covered below.
Why this works: a PAM primer
If you’ve never touched PAM, here’s the short version. PAM (Pluggable Authentication Modules) is the layer Linux uses to decide how a login actually happens: password checks, two-factor, account restrictions, session setup. All of it is modular, and every application gets its own config file under /etc/pam.d/.
sshd has one: /etc/pam.d/sshd. It gets read at four distinct phases of a login:
- auth – is this actually you? Password, key, 2FA.
- account – are you allowed in right now? Expired account, time-of-day restrictions, that kind of thing.
- session – set up the environment: mount home directories, write to
utmp/wtmp, run session hooks. Runs once on login, once on logout. - password – only relevant when a password is being changed.
Login notifications hook into the session phase, because it’s guaranteed to run exactly once per login and once per logout, no matter how the user got in.
That last part matters more than it sounds like it should. The obvious wrong answer is dropping a notification script in /etc/profile or ~/.bashrc. Those only fire for interactive shells. Try this:
ssh myserver 'whoami'
No interactive shell gets spawned, so /etc/profile never runs. But it’s still a login, and PAM still processes it. If the goal is catching logins rather than “logins that happen to hand you a shell prompt,” the session phase is the only layer without a loophole.
The mechanism: pam_exec.so
pam_exec.so is a PAM module that runs an external program at a given phase and hands it context through environment variables. The useful ones here are $PAM_USER, $PAM_RHOST, and $PAM_TYPE (which is either open_session or close_session).
One line at the bottom of /etc/pam.d/sshd wires it in:
session optional pam_exec.so /usr/bin/sshd-login
optional means a failure here doesn’t block the login. Good, because a flaky webhook shouldn’t be able to lock you out of your own server.
The script
#!/bin/bash
WEBHOOK_URL="https://discord.com/api/webhooks/..."
case "$PAM_TYPE" in
open_session)
PAYLOAD=" { "content": "$PAM_USER logged into $HOSTNAME (remote host: $PAM_RHOST)." }"
;;
close_session)
PAYLOAD=" { "content": "$PAM_USER logged out of $HOSTNAME (remote host: $PAM_RHOST)." }"
;;
esac
if [ -n "$PAYLOAD" ] ; then
curl -X POST -H 'Content-Type: application/json' -d "$PAYLOAD" "$WEBHOOK_URL" &
fi
The one change from the original: the trailing & on the curl call. Without it, the login just sits there waiting for Discord’s API to respond before handing you a prompt. Small fix, noticeable difference.
One more thing worth saying plainly: the webhook URL is a credential, not a config value. Anyone who has it can post into your channel. Don’t commit it anywhere.
Porting to Ubuntu: the silence
Moved this setup from a RHEL box to an Ubuntu box and it just went quiet. No errors, no Discord messages, nothing. Checked AppArmor confinement on sshd. Checked the ordering of the @include lines in /etc/pam.d/sshd. Checked whether curl was even reachable in the minimal environment pam_exec gives its child process. All fine.
The actual problem was permissions. The script wasn’t executable, and pam_exec cares about more than the +x bit:
sudo chown root:root /usr/bin/sshd-login
sudo chmod 755 /usr/bin/sshd-login
Wrong owner, or group/world writable, and pam_exec just quietly refuses to run it. Nothing shows up in the auth log to explain why. It just doesn’t fire, and you’re left staring at a Discord channel that has never said a word.
Silence fixed. Now every login gets shouted into the void, and whatever happens to the box afterward, there’s at least one record sitting somewhere the attacker can’t reach: a timestamp, a username, an IP, ready to grep later.