Background: The All‑Appealing Passwordless SSH Model

Many teams adopt passwordless SSH keys for their CI/CD jobs because the workflow looks simple: generate a key pair, drop the public key onto the target host, and let the runner log in without a password prompt. On the surface this removes the need for interactive authentication and speeds up deployments. However, the convenience comes with a set of hidden dangers that are often overlooked until a breach occurs.

Why the Model Is Dangerous in Automated Pipelines

A CI/CD runner typically runs with elevated permissions on a shared build fleet. If a private key is stored in plain text inside a repository, a malicious contributor, a compromised runner, or even a careless fork can extract that key and gain unfettered SSH access to any server that trusts the corresponding public key. The risk is amplified when the same key is reused across multiple environments (dev, staging, production) or when the key grants root‑level access.

# Example: Accidentally committing a private key
git add id_rsa
git commit -m "Add SSH key for deployment"
git push origin main
# The key is now in the repo's history and can be recovered with:
git log --all --grep='PRIVATE KEY'

The snippet above shows how a single git add can expose a private key forever. Even after removing the file and rewriting history, cached copies may linger in remote mirrors, CI caches, or forked repositories.

Hidden Internals: How Attackers Leverage Stolen Keys

Once an attacker obtains a private key, they can:

  • Pivot laterally by adding their own authorized keys to other hosts.
  • Escalate privileges if the original key is bound to a sudo‑enabled account.
  • Exfiltrate secrets stored on the host (API tokens, database credentials).
  • Maintain persistence by placing back‑doors that survive key rotation.

The following script demonstrates how an attacker could enumerate all accounts that accept the stolen key on a target host:

# attacker_enumerate.sh
#!/usr/bin/env bash
TARGET=$1
KEY=$2

ssh -i "$KEY" "$TARGET" "awk -F: '/^.*:.*:.*:.*:.*:.*:.*$/ {print $1}' /etc/passwd"

By running the script against every server in the inventory, the attacker quickly builds a map of privileged accounts that trust the compromised key.

Step‑by‑Step Remediation Guide

Below is a practical tutorial that replaces the insecure passwordless key with a more robust approach based on short‑lived SSH certificates and a secret‑management layer. The guide assumes you are using GitHub Actions as the CI engine, but the concepts translate to GitLab, Azure Pipelines, or any other runner.

1. Generate a Certificate Authority (CA) Key

The CA will sign short‑lived host certificates. Store the CA private key in a vault (e.g., HashiCorp Vault, AWS Secrets Manager) and never commit it to source control.

# On a secure admin workstation
ssh-keygen -f ca_ssh -C "ci-cd-ca" -N ""
# ca_ssh (private) stays in the vault
# ca_ssh.pub is distributed to all target hosts

2. Distribute the CA Public Key to Target Hosts

Append the CA public key to /etc/ssh/ca.pub on each server and configure sshd_config to trust certificates signed by this CA.

# On each target host (as root)
cat ca_ssh.pub >> /etc/ssh/ca.pub
echo "TrustedUserCAKeys /etc/ssh/ca.pub" >> /etc/ssh/sshd_config
systemctl restart sshd

3. Create a One‑Time Signing Script in the CI Pipeline

The CI job retrieves a short‑lived private key from the vault, signs it with the CA, and uses the resulting certificate for the deployment step. The certificate is set to expire after a few minutes, limiting the window of abuse.

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Retrieve temporary SSH key
        id: get_key
        run: |
          # Retrieve key from secret store (example uses AWS Secrets Manager)
          aws secretsmanager get-secret-value --secret-id ci/ssh/temp-key --query SecretString --output text > temp_key
          chmod 600 temp_key

      - name: Sign temporary key with CA
        run: |
          ssh-keygen -s ${{ secrets.CA_PRIVATE_KEY }} -I github-action-${{ github.run_id }} -V +5m -z ${{ github.run_number }} -n ubuntu temp_key.pub
          # The signed certificate is temp_key-cert.pub

      - name: Deploy to server
        env:
          SSH_CERT: temp_key-cert.pub
        run: |
          ssh -i temp_key -i $SSH_CERT ubuntu@my-prod-server <<'EOF'
          cd /var/www/app
          git pull origin main
          ./restart.sh
          EOF

In this workflow:

  • The private key lives only in the runner’s memory and is destroyed at the end of the job.
  • The certificate (temp_key-cert.pub) expires after five minutes, rendering any leaked key useless after that window.
  • The CA private key (${{ secrets.CA_PRIVATE_KEY }}) is stored as a GitHub secret, never exposed in the repository.

4. Enforce Key Rotation and Auditing

Implement a periodic audit that scans authorized_keys and ~/.ssh/authorized_keys for any entries that are not certificates. The following script can be added to a nightly cron job on each host:

# audit_ssh_certs.sh
#!/usr/bin/env bash
LOG=/var/log/ssh_cert_audit.log
date > $LOG
find /home -type f -name authorized_keys -o -path /root/.ssh/authorized_keys | while read file; do
  if grep -q "ssh-rsa" "$file"; then
    echo "Plain RSA key found in $file" >> $LOG
  fi
done
# Optionally, send $LOG to a centralized SIEM

Any detection triggers an alert, prompting immediate revocation of the offending key and regeneration of certificates.

Security and Best Practices

The following checklist helps harden CI/CD SSH usage:

  • Never store private keys in source control. Use secret‑management services with audit trails.
  • Prefer SSH certificates over raw keys. Short‑lived certificates reduce the blast radius of a leak.
  • Scope keys to the minimum required host and user. Use principals in the certificate to limit access.
  • Rotate certificates frequently. Automate expiration intervals of 5–15 minutes for CI jobs.
  • Enable AuthorizedPrincipalsFile on the server. This forces the server to accept only known principals.
  • Monitor auth.log for anomalous SSH authentication patterns. Look for logins from unexpected IP ranges or at odd hours.
“Treat every private key as a password that never expires. If you can’t rotate it daily, you shouldn’t be using it at all.”

Conclusion

Passwordless SSH keys may appear to streamline CI/CD pipelines, but the hidden costs in terms of credential leakage, lateral movement, and long‑term persistence are substantial. By swapping static keys for short‑lived SSH certificates, storing secrets in a vault, and adding automated audits, teams can retain the convenience of automated deployments while dramatically shrinking the attack surface.

The code snippets above provide a concrete, end‑to‑end example that you can adapt to your own environment. Implementing these changes today will protect tomorrow’s production systems from a class of attacks that many organizations still consider “unlikely.”