SSH key rotation means replacing long-lived keys with fresh ones on a schedule, so a key that leaks today stops working in weeks rather than years. This guide shows you how to enforce expiry dates inside authorized_keys, rotate keys with a script, and audit what is left behind on an Ubuntu, Debian, AlmaLinux or Rocky Linux VPS.
Before you start
- A Linux VPS with root or sudo access running OpenSSH 7.6 or newer. Ubuntu 22.04/24.04, Debian 12 and AlmaLinux/Rocky 8–9 all qualify. Check the version with
ssh -Von the server and confirmexpiry-timesupport in your OpenSSH release notes. - A working key-based login already in place. If not, start with Set up SSH keys.
- A second SSH session open while you test, so a mistake does not lock you out.
- Basic comfort with cron and shell scripts.
Warning: Never remove your only working key before a replacement has logged in successfully. Keep one session open during every change.
Build an SSH key rotation policy
Decide the rules before you touch any config. A workable default for a single VPS:
- One key per person and per machine, never shared between people.
- Ed25519 keys only; no RSA below 3072 bits.
- Maximum lifetime of 90 days for human keys, 30–90 days for automation keys.
- A 7-day overlap where the old and new key both work.
- Immediate revocation when a laptop is lost or a contractor leaves.
Write this down in a file such as docs/ssh-key-policy.md so the automation has one source of truth.
Tag keys with an expiry date
OpenSSH lets you attach an expiry to a single line in authorized_keys. Once the date passes, the key is refused even though the line is still there.
- Open the file for the account you log in as (example user
deploy):
sudo -u deploy nano /home/deploy/.ssh/authorized_keys- Put the option before the key type on the line:
expiry-time="20260401" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... deploy@laptop-2026q1The value is UTC, either YYYYMMDD or YYYYMMDDHHMM. Everything else on the line stays the same.
- Fix ownership and permissions. sshd reads the file on each connection, so no reload is needed:
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.sshTip: Combine expiry-time with from="203.0.113.10" to pin a key to one source address as well.
Generate and deploy a rotated key
Do this on your workstation, not on the server.
- Create a new key with a comment that records the quarter:
ssh-keygen -t ed25519 -a 100 -C "deploy@laptop-2026q2" -f ~/.ssh/id_ed25519_deploy_2026q2- Install the public key in one command:
ssh deploy@203.0.113.10 'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys' < ~/.ssh/id_ed25519_deploy_2026q2.pubThen edit that line to add expiry-time="20260701" as shown above.
- Point your SSH config at the new key so you never type
-iagain:
Host vps
HostName 203.0.113.10
User deploy
IdentityFile ~/.ssh/id_ed25519_deploy_2026q2
IdentitiesOnly yes- Log in and confirm which key was offered:
ssh -v vps 2>&1 | grep "Offering public key"Automate rotation with a script and cron
Manual rotation gets skipped. A small client-side script keeps it honest.
#!/usr/bin/env bash
set -euo pipefail
HOST="deploy@203.0.113.10"
STAMP="$(date -u +%Y%m%d)"
EXPIRY="$(date -u -d '+90 days' +%Y%m%d)"
KEY="$HOME/.ssh/id_ed25519_deploy_$STAMP"
ssh-keygen -t ed25519 -a 100 -N "" -C "deploy@auto-$STAMP" -f "$KEY"
LINE="expiry-time=\"$EXPIRY\" $(cat "$KEY.pub")"
ssh "$HOST" "umask 077; echo '$LINE' >> ~/.ssh/authorized_keys"
ssh -i "$KEY" -o IdentitiesOnly=yes "$HOST" true
echo "rotated to $KEY, expires $EXPIRY"Run it weekly from cron. The script only adds a key, so it is safe to repeat; removing old keys stays a separate, deliberate step.
Warning: Test the script by hand first. A cron job that appends broken lines can fill authorized_keys and slow down every login.
For server-side automation, AuthorizedKeysCommand in sshd_config can pull keys from a database or configuration management tool, so expiry is enforced centrally. Check your OpenSSH version's documentation before enabling it.
Audit and remove stale keys
- List keys with fingerprints and comments:
ssh-keygen -lf /home/deploy/.ssh/authorized_keys- Find lines with no expiry option:
grep -v 'expiry-time' /home/deploy/.ssh/authorized_keys- Delete lines for people who left or machines that are gone, then confirm login still works from a fresh terminal.
- Watch for rejections in the log:
sudo grep 'Failed publickey' /var/log/auth.log | tail -20On AlmaLinux and Rocky the file is /var/log/secure.
Check it works
- Log in with the new key from a new terminal:
ssh -i ~/.ssh/id_ed25519_deploy_2026q2 deploy@203.0.113.10. - Test expiry by setting a spare key to yesterday and connecting. sshd should refuse it and log a failed publickey entry.
- Run
ssh-keygen -lfagain and confirm every line has anexpiry-timein the future. - Confirm the scheduled job ran:
journalctl -u ssh-key-rotateorgrep CRON /var/log/syslog.
Troubleshooting
- Permission denied (publickey) right after rotation — the new key was appended without a trailing newline, so sshd read it as part of the previous line. Check with
tail -c 50 authorized_keysand rewrite the line. - Key still works after its expiry date — the server's OpenSSH is older than 7.6 and ignores
expiry-time. Upgrade OpenSSH or enforce expiry withAuthorizedKeysCommandinstead. - "Too many authentication failures" — your client is offering every key in
~/.ssh. AddIdentitiesOnly yesto the host block, or read Fix SSH "Too many authentication failures". - Locked out completely — log in through the browser console in your VPS provider's control panel, then repair
authorized_keysor its permissions. - Cron job never runs — cron has a minimal environment. Use full paths and set
HOMEexplicitly inside the script.
Next steps
- Disable root login and password authentication over SSH so keys become the only way in.
- [Disable weak SSH
