A PAM (Pluggable Authentication Module) for SSH that sends one-time verification codes or approval links via push notification services like ntfy, Pushover, Telegram, and 80+ others.
- User connects via SSH (with SSH key authentication)
- PAM module generates a random 4-digit code
- Code is sent to user's phone/device via push notification
- User enters the code at the SSH prompt
- Access is granted if the code is correct
+----------+ SSH Key +----------+ Push +----------+
| User | --------------> | Server | -----------> | Phone |
| | | (PAM) | "1234" | |
| | <-------------- | | | |
| | Enter Code: | | | |
| | --------------> | | | |
| | "1234" | | | |
| | <-------------- | | | |
| | Access OK | | | |
+----------+ +----------+ +----------+
- User connects via SSH (with SSH key authentication)
- PAM module creates an approval request
- Approval link is sent to user's phone via push notification
- User clicks the link on their phone
- Access is granted automatically (no typing required!)
+----------+ SSH Key +----------+ Push +----------+
| User | --------------> | Server | -----------> | Phone |
| | | (PAM) | [Link] | |
| | <-------------- | | | |
| | Waiting... | | | |
| | | | <----------- | |
| | | | Click Link | |
| | <-------------- | | | |
| | Access OK | | | |
+----------+ +----------+ +----------+
- Push-based codes: No app to open, code comes to you
- Link-based approval: Click a link instead of typing a code
- Multiple notification services: ntfy, Pushover, Telegram, Slack, Discord, email, and 80+ more
- Per-user configuration: Each user can have different auth method and notification service
- Redundancy support: Send to multiple services simultaneously
- Configurable timeouts: Codes/links expire after 5 minutes by default
- Bypass options: Skip 2FA for specific users or networks
- Full logging: Track all authentication attempts
- Well-documented code: Easy to audit, modify, and debug
This module supports four authentication methods, configurable per-user:
| Method | Description | User Experience |
|---|---|---|
code |
Send a 4-digit code | User types the code |
link |
Send an approval link | User clicks link on phone (no typing!) |
both |
Send code AND link | User can type code OR click link then press Enter |
none |
Skip 2FA | No verification required |
For users who prefer not to type codes, link-based auth lets them simply click a link in the notification. This requires running the approval server.
Setup:
-
Configure the server URL in config.ini:
[server] port = 9110 url = http://your-server.example.com:9110
-
Open the firewall port:
sudo ufw allow 9110/tcp
-
Start the approval server:
sudo systemctl enable --now pam-ssh-2fa-server -
Set per-user auth method:
# In /etc/pam-ssh-2fa/users/doug.conf [auth] method = link
- Debian 11+ or Ubuntu 20.04+
- Python 3.8+
- SSH key authentication configured
- A push notification service (ntfy.sh is free and easy)
git clone <this-repo> pam-ssh-2fa
cd pam-ssh-2fa
sudo ./install.shEdit /etc/pam-ssh-2fa/config.ini and add your notification URL:
[notifications]
# Free option - ntfy.sh (use a random topic name)
apprise_urls = ntfy://ntfy.sh/my-secret-ssh-codes-abc123xyzsudo python3 /etc/pam-ssh-2fa/pam_ssh_2fa.py --test-notifyCheck your phone - you should receive a test code!
Add to /etc/pam.d/sshd (after @include common-auth):
auth required pam_python.so /etc/pam-ssh-2fa/pam_ssh_2fa.py
Edit /etc/ssh/sshd_config:
UsePAM yes
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive:pam
# In a NEW terminal, try connecting
ssh user@your-server
# You should:
# 1. Authenticate with your SSH key
# 2. Receive a push notification with a code
# 3. Be prompted to enter the codeOnly after testing works:
sudo systemctl restart sshdLink-based auth lets users click a link instead of typing a code. This requires the approval server.
Edit /etc/pam-ssh-2fa/config.ini:
[server]
port = 9110
url = http://YOUR_PUBLIC_IP_OR_HOSTNAME:9110The URL must be reachable from the user's phone. Options:
- Public IP:
http://203.0.113.50:9110 - Public hostname:
http://ssh.example.com:9110 - Tailscale:
http://myserver.tailnet.ts.net:9110
# UFW
sudo ufw allow 9110/tcp
# firewalld
sudo firewall-cmd --permanent --add-port=9110/tcp
sudo firewall-cmd --reload
# iptables
sudo iptables -A INPUT -p tcp --dport 9110 -j ACCEPTsudo systemctl enable --now pam-ssh-2fa-server# Check status
systemctl status pam-ssh-2fa-server
# Test health endpoint (from server)
curl http://localhost:9110/health
# Test from external (from your phone's browser or another machine)
curl http://YOUR_SERVER:9110/healthCreate per-user config files:
# /etc/pam-ssh-2fa/users/doug.conf
[notifications]
apprise_urls = pover://USERKEY@APPTOKEN
[auth]
method = linkOr set as default for all users:
# In /etc/pam-ssh-2fa/config.ini
[users]
auth_method = link# Open a new SSH connection
ssh user@your-server
# You should see: "Approval link sent to your device. Waiting for approval..."
# Check your phone for the notification
# Click the link
# SSH session should grant accessThis module uses Apprise for notifications, which supports 80+ services. Here are common examples:
Different users can receive codes via different services and use different auth methods. Create per-user config files:
/etc/pam-ssh-2fa/users/doug.conf # Doug uses Pushover with link auth
/etc/pam-ssh-2fa/users/ben.conf # Ben uses ntfy with both options
Example:
# /etc/pam-ssh-2fa/users/doug.conf
[notifications]
apprise_urls = pover://DOUG_USER_KEY@APP_TOKEN
[auth]
method = linkSee the Configuration Reference section for all per-user options.
Users without a personal config file use the global settings from config.ini.
Free, open-source, works immediately with no account:
apprise_urls = ntfy://ntfy.sh/your-random-topic-nameSecurity note: Anyone who knows your topic name can subscribe. Use a long random string, or self-host ntfy for private use.
Reliable, full-featured:
apprise_urls = pover://YOUR_USER_KEY@YOUR_APP_TOKENGet credentials at https://pushover.net
Free, requires creating a bot:
apprise_urls = tgram://BOT_TOKEN/CHAT_IDapprise_urls = ntfy://ntfy.sh/my-topic, pover://user@tokenSee the Apprise Wiki for all supported services.
All settings are in /etc/pam-ssh-2fa/config.ini:
| Setting | Default | Description |
|---|---|---|
debug |
false |
Enable verbose logging |
log_file |
/var/log/pam-ssh-2fa.log |
Log file location |
| Setting | Default | Description |
|---|---|---|
length |
4 |
Number of digits in code |
timeout |
300 |
Seconds until code/link expires |
max_attempts |
3 |
Failed attempts before lockout |
storage_dir |
/var/run/pam-ssh-2fa |
Temporary code storage |
| Setting | Default | Description |
|---|---|---|
apprise_urls |
(empty) | Comma-separated notification URLs |
title |
SSH Login |
Notification title |
body |
(template) | Body template for code-only auth |
body_link |
(template) | Body template for link-only auth |
body_both |
(template) | Body template for code + link auth |
Template variables: {code}, {link}, {user}, {host}, {rhost}, {timeout}
| Setting | Default | Description |
|---|---|---|
prompt |
Enter verification code: |
Prompt for code-only auth |
prompt_both |
Enter code OR press Enter after clicking link: |
Prompt for both auth |
success |
Verification successful. |
Success message |
failure |
Verification failed. |
Failure message |
expired |
Code expired... |
Expiration message |
| Setting | Default | Description |
|---|---|---|
users |
(empty) | Comma-separated usernames to skip 2FA |
networks |
(empty) | Comma-separated CIDR ranges to skip 2FA |
Example:
[bypass]
users = ansible, backup
networks = 192.168.1.0/24, 10.0.0.0/8| Setting | Default | Description |
|---|---|---|
allow_unconfigured_users |
false |
If true, users without config bypass 2FA; if false, denied |
auth_method |
code |
Default authentication method for all users |
allow_unconfigured_users controls what happens when a user has no notification URLs configured:
false(default, recommended): Users without config are denied with an error messagetrue(use during rollout): Users without config bypass 2FA entirely
auth_method sets the default authentication method:
code- Send a 4-digit code, user types it in (default)link- Send an approval link, user clicks it (no typing)both- Send both code and link, user can use eithernone- Skip 2FA entirely
Example:
[users]
allow_unconfigured_users = false
auth_method = codeRequired for link-based authentication (auth_method = link or both):
| Setting | Default | Description |
|---|---|---|
port |
9110 |
Port the approval server listens on |
url |
(empty) | Public URL for approval links (REQUIRED for link auth) |
log_file |
/var/log/pam-ssh-2fa-server.log |
Approval server log file |
The url must be reachable from the user's phone. Examples:
[server]
port = 9110
url = http://203.0.113.50:9110 # Public IP
url = http://myserver.example.com:9110 # Public hostname
url = http://myserver.tailnet.ts.net:9110 # TailscaleCreate files in /etc/pam-ssh-2fa/users/<username>.conf to customize settings per user.
# /etc/pam-ssh-2fa/users/doug.conf
[notifications]
# User's notification service
apprise_urls = pover://DOUG_USER_KEY@APP_TOKEN
# Optional: custom notification templates
# title = SSH Login for Doug
# body = Your code: {code}
# body_link = Click to approve: {link}
# body_both = Click {link} or enter {code}
[auth]
# Authentication method for this user
# Options: code, link, both, none
method = link| Method | Description | When to Use |
|---|---|---|
code |
4-digit code | Default, works everywhere |
link |
Click to approve | No typing, best UX |
both |
Code or link | Maximum flexibility |
none |
Skip 2FA | Emergency/service accounts |
Doug uses Pushover with link-only auth:
# /etc/pam-ssh-2fa/users/doug.conf
[notifications]
apprise_urls = pover://USERKEY@APPTOKEN
[auth]
method = linkBen uses ntfy with both options:
# /etc/pam-ssh-2fa/users/ben.conf
[notifications]
apprise_urls = ntfy://ntfy.sh/ben-secret-topic
[auth]
method = bothService account skips 2FA:
# /etc/pam-ssh-2fa/users/ansible.conf
[auth]
method = noneIn /etc/pam-ssh-2fa/config.ini:
[general]
debug = trueThen check /var/log/pam-ssh-2fa.log and journalctl -u sshd.
Use pamtester:
sudo apt install pamtester
sudo pamtester sshd yourusername authenticateNo notification received:
- Check your Apprise URL format
- Run the self-test:
python3 /etc/pam-ssh-2fa/pam_ssh_2fa.py --test-notify - Test specific user:
python3 /etc/pam-ssh-2fa/test_notify.py --user doug - Check if outbound HTTPS is allowed
SSH hangs after key auth:
- PAM module might be failing - check logs
- Ensure
KbdInteractiveAuthentication yesis set
Code rejected even when correct:
- Check server time is accurate (NTP)
- Code may have expired (default 5 minutes)
- Check for trailing spaces when entering code
Link-based auth not working:
- Check approval server is running:
systemctl status pam-ssh-2fa-server - Verify
[server] urlis set in config.ini - Ensure firewall allows the port:
sudo ufw allow 9110/tcp - Test URL is reachable from phone:
curl http://your-server:9110/health - Check approval server log:
tail -f /var/log/pam-ssh-2fa-server.log
"2FA not configured for this user" error:
- User has no per-user config AND no global apprise_urls
- Either create
/etc/pam-ssh-2fa/users/<username>.conf - Or set
allow_unconfigured_users = true(less secure)
Locked out:
- Use console/IPMI/serial access
- Edit
/etc/pam.d/sshdto remove the PAM line - Restart SSH
# PAM module log
sudo tail -f /var/log/pam-ssh-2fa.log
# Approval server log (if using link auth)
sudo tail -f /var/log/pam-ssh-2fa-server.log
# System auth log
sudo journalctl -u sshd -f
# PAM debug
sudo grep pam /var/log/auth.log# Check server is running
systemctl status pam-ssh-2fa-server
# Check health endpoint
curl http://localhost:9110/health
# Check from external (replace with your URL)
curl http://your-server:9110/health- Keep your notification topic/URL secret - It's effectively a shared secret
- Use HTTPS for self-hosted notification services
- Set appropriate timeouts - Balance security vs usability (default 5 min)
- Monitor logs for failed authentication attempts
- Test thoroughly before deploying to production
- Have a recovery plan - Console access, bypass user, etc.
- Approval server exposure - The approval server must be internet-accessible for link auth. Consider:
- Use a firewall to limit source IPs if possible
- Tokens are cryptographically random and single-use
- Consider reverse proxy with HTTPS for additional security
- Per-user configs - Store API keys in per-user configs with 0600 permissions
- Avoid
allow_unconfigured_users = truein production - it bypasses 2FA for unknown users
/etc/pam-ssh-2fa/
|-- pam_ssh_2fa.py # Main PAM module (Python)
|-- approval_server.py # Approval server for link-based auth
|-- config.ini # Global configuration file
+-- users/ # Per-user configuration directory
|-- doug.conf # Doug's notification settings
+-- ben.conf # Ben's notification settings
/etc/systemd/system/
+-- pam-ssh-2fa-server.service # Systemd service for approval server
/var/run/pam-ssh-2fa/ # Runtime storage (tmpfs recommended)
|-- code_*.json # OTP code files
+-- approvals/ # Approval request files
+-- <token>.json
/var/log/pam-ssh-2fa.log # PAM module log
/var/log/pam-ssh-2fa-server.log # Approval server log
sudo ./install.sh --uninstallThen manually:
- Remove the PAM line from
/etc/pam.d/sshd - Revert
/etc/ssh/sshd_configchanges - Stop approval server:
sudo systemctl disable --now pam-ssh-2fa-server - Restart SSH:
sudo systemctl restart sshd
The module consists of two main components:
A single Python file with these main components:
- Config - Loads settings from INI file, supports per-user overrides
- PAMLogger - Writes to both file and syslog
- CodeManager - Generates, stores, validates OTPs
- ApprovalManager - Creates/checks link-based approval requests
- NotificationSender - Sends via Apprise (80+ services)
- BypassChecker - Determines if 2FA should be skipped
- pam_sm_authenticate - Main PAM entry point
A lightweight HTTP server for link-based authentication:
- ApprovalManager - Reads/writes approval request files
- ApprovalRequestHandler - Handles HTTP requests
- CleanupThread - Removes expired approvals
- Endpoints:
GET /approve/<token>- Mark approval as grantedGET /health- Health check
The code is extensively commented for easy auditing and modification.
Contributions welcome! Areas for improvement:
- Rate limiting (per-user, per-IP)
- WebAuthn/FIDO2 support
- Backup codes
- Time-based lockouts
- Integration tests
MIT License - Use and modify freely.
- Apprise - Amazing multi-service notification library
- pam-python - Python PAM module framework
- ntfy - Simple, free push notifications