Why the “simple SSH key” approach looks attractive

Many teams bootstrap a deployment pipeline by copying a static id_rsa private key into the CI runner, granting it password‑less access to the target host. The allure is obvious: a single line in a .gitlab-ci.yml or GitHub Actions workflow can push code, restart services, or run migrations. However, that convenience masks a series of operational and security defects that only surface under real‑world load.

Hidden pitfalls of static key authentication

1. Unlimited lifetime. An ssh‑key rarely expires. If a developer leaves the company, the key may remain on the CI server, on backup snapshots, or in shared repositories.

2. No host verification. The default known_hosts file is often disabled in automated scripts (StrictHostKeyChecking=no), opening the door to man‑in‑the‑middle attacks.

3. Lack of granularity. One key can grant full root access across every environment (dev, staging, prod). Revoking or rotating the key forces a painful coordinated rollout.

4. Auditing blind spots. SSH logs only show a key fingerprint, not the identity of the actor that requested the operation. When a breach occurs, tracing the exact pipeline step is difficult.

# Example of a vulnerable CI step
- name: Deploy to prod
  run: |
    ssh -o StrictHostKeyChecking=no -i ${{ secrets.SSH_KEY }} [email protected] \
      "git pull && systemctl restart myapp"

The snippet above demonstrates the typical pattern: a private key stored as a secret, host key checking disabled, and a single command that performs a privileged action. Below we replace this fragile model with an SSH‑certificate based workflow that mitigates each of the listed risks.

Understanding SSH certificates

OpenSSH supports a lightweight PKI model where a dedicated Certificate Authority (CA) signs host and user keys. The signed certificates embed:

  • Validity period (seconds, days, weeks)
  • Allowed principals (e.g., ci-deployer)
  • Critical options (e.g., no-pty, restrict)

Because the CA’s public key is the only trusted material on the server, revoking a compromised user key is as simple as refusing to sign new certificates or adjusting the AuthorizedPrincipalsFile.

# Generate a CA key pair (run once on a secure admin workstation)
ssh-keygen -f ssh_ca -N "" -t ed25519

# Extract the public part for distribution
cat ssh_ca.pub

Distribute ssh_ca.pub to every target host and add it to /etc/ssh/sshd_config as a trusted signer:

# /etc/ssh/sshd_config additions
TrustedUserCAKeys /etc/ssh/ca_keys/ssh_ca.pub
AuthorizedPrincipalsFile /etc/ssh/ca_keys/%u

The AuthorizedPrincipalsFile maps each Linux user to a file that lists the allowed principal names. For the CI user ci‑deployer, create /etc/ssh/ca_keys/ci‑deployer containing:

ci-deployer

Signing a CI user key on demand

Instead of storing a static private key on the CI server, generate an ephemeral key pair at pipeline start, ask the CA to sign it with a short TTL (e.g., 10 minutes), and use the resulting certificate for the SSH session. This approach provides:

  • Automatic expiration – no lingering credentials.
  • Auditable principal names – logs show ci-deployer rather than a fingerprint.
  • Fine‑grained command restrictions via restrict and no-pty.

Below is a Bash helper that the CI job can invoke. It expects the CA private key to be available as a secret (encrypted at rest) and uses ssh-keygen to produce a signed certificate.

# ci_sign.sh – generate a one‑time certificate
#!/usr/bin/env bash
set -euo pipefail

# Paths inside the CI runner container
CA_KEY="${HOME}/.ssh/ssh_ca"
TMP_DIR=$(mktemp -d)

# 1. Generate an ephemeral key pair
ssh-keygen -t ed25519 -f "${TMP_DIR}/ci_key" -N "" -q

# 2. Sign the public key – valid for 600 seconds (10 min)
ssh-keygen -s "${CA_KEY}" \
  -I "ci-deployer-$(date +%s)" \
  -n ci-deployer \
  -V +10m \
  -O clear \
  -O no-pty \
  -z 1 \
  "${TMP_DIR}/ci_key.pub"

# 3. Export environment variables for the subsequent ssh call
export SSH_PRIVATE_KEY="${TMP_DIR}/ci_key"
export SSH_CERTIFICATE="${TMP_DIR}/ci_key-cert.pub"

# 4. Print a small JSON payload for the CI step (optional)
cat <<EOF
{
  "private_key_path": "${SSH_PRIVATE_KEY}",
  "certificate_path": "${SSH_CERTIFICATE}"
}
EOF

In a GitHub Actions workflow, the script can be called as a step, and the generated files can be passed to the ssh command via ssh -i and -o CertificateFile= options:

# .github/workflows/deploy.yml excerpt
jobs:
  deploy:
    runs-on: ubuntu‑latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Install OpenSSH
        run: sudo apt‑get update && sudo apt‑get install -y openssh-client

      - name: Generate short‑lived certificate
        id: cert
        run: |
          chmod +x ./ci_sign.sh
          ./ci_sign.sh > cert.json
          echo "PRIVATE_KEY=$(jq -r .private_key_path cert.json)" >> $GITHUB_ENV
          echo "CERT=$(jq -r .certificate_path cert.json)" >> $GITHUB_ENV

      - name: Deploy to staging
        env:
          SSH_PRIVATE_KEY: ${{ env.PRIVATE_KEY }}
          SSH_CERT: ${{ env.CERT }}
        run: |
          chmod 600 "$SSH_PRIVATE_KEY"
          ssh -i "$SSH_PRIVATE_KEY" -o CertificateFile="$SSH_CERT" \
            -o StrictHostKeyChecking=yes [email protected] \
            "cd /var/www/app && git pull && sudo systemctl reload app"

Notice the removal of StrictHostKeyChecking=no. The host key is verified against the known_hosts file, which can be pre‑populated via a separate step that fetches the fingerprint from a trusted source (e.g., a vault). This eliminates the MITM exposure present in the naive approach.

Automating host key verification

Storing the server’s fingerprint in a secret vault (AWS Secrets Manager, HashiCorp Vault, etc.) allows the CI runner to update ~/.ssh/known_hosts safely:

# fetch_known_host.sh – retrieve fingerprint from Vault
#!/usr/bin/env bash
set -euo pipefail

HOST=$1
# Example using AWS Secrets Manager
FINGERPRINT=$(aws secretsmanager get-secret-value \
  --secret-id "ssh/known_hosts/${HOST}" \
  --query SecretString --output text)

# Append to known_hosts
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "${FINGERPRINT}" >> ~/.ssh/known_hosts

Add this script as a preceding step in the workflow. The CI pipeline now has a complete chain of trust:

  • CA‑signed, time‑bound user certificate.
  • Verified server host key from a centrally managed secret.
  • Least‑privilege restrictions (no-pty, command‑whitelisting).

Security and Best Practices

Rotate the CA key regularly. Treat the CA private key like any other high‑value credential – store it offline, rotate annually, and audit its usage.

Limit certificate privileges. Use the -O force-command= option when signing to constrain the CI user to a single script on the remote host, preventing arbitrary command execution if the certificate is compromised.

# Example: force a single deployment script
ssh-keygen -s "${CA_KEY}" \
  -I "ci-deployer-$(date +%s)" \
  -n ci-deployer \