Security

SSH Hardening: Enforcing Key Rotation Policies for Automated Security

SSH key rotation replaces long-lived credentials before they become a permanent backdoor. This guide covers expiry dates in authorized_keys, a rotation script and auditing.

AdvancedLinux server5 min read

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 -V on the server and confirm expiry-time support 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.

  1. Open the file for the account you log in as (example user deploy):
Bash
sudo -u deploy nano /home/deploy/.ssh/authorized_keys
  1. Put the option before the key type on the line:
expiry-time="20260401" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... deploy@laptop-2026q1

The value is UTC, either YYYYMMDD or YYYYMMDDHHMM. Everything else on the line stays the same.

  1. Fix ownership and permissions. sshd reads the file on each connection, so no reload is needed:
Bash
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh

Tip: 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.

  1. Create a new key with a comment that records the quarter:
Bash
ssh-keygen -t ed25519 -a 100 -C "deploy@laptop-2026q2" -f ~/.ssh/id_ed25519_deploy_2026q2
  1. Install the public key in one command:
Bash
ssh deploy@203.0.113.10 'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys' < ~/.ssh/id_ed25519_deploy_2026q2.pub

Then edit that line to add expiry-time="20260701" as shown above.

  1. Point your SSH config at the new key so you never type -i again:
Host vps
    HostName 203.0.113.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy_2026q2
    IdentitiesOnly yes
  1. Log in and confirm which key was offered:
Bash
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.

Bash
#!/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

  1. List keys with fingerprints and comments:
Bash
ssh-keygen -lf /home/deploy/.ssh/authorized_keys
  1. Find lines with no expiry option:
Bash
grep -v 'expiry-time' /home/deploy/.ssh/authorized_keys
  1. Delete lines for people who left or machines that are gone, then confirm login still works from a fresh terminal.
  1. Watch for rejections in the log:
Bash
sudo grep 'Failed publickey' /var/log/auth.log | tail -20

On 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 -lf again and confirm every line has an expiry-time in the future.
  • Confirm the scheduled job ran: journalctl -u ssh-key-rotate or grep 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_keys and 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 with AuthorizedKeysCommand instead.
  • "Too many authentication failures" — your client is offering every key in ~/.ssh. Add IdentitiesOnly yes to 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_keys or its permissions.
  • Cron job never runs — cron has a minimal environment. Use full paths and set HOME explicitly inside the script.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.