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-deployerrather than a fingerprint. - Fine‑grained command restrictions via
restrictandno-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 \
♥ Enjoyed this article? Use the like banner at the top
of the page to let us know — you can like it more than once! Each
additional like from the same reader carries a little less weight than
the first, so our appreciation scores reflect genuine enthusiasm rather
than accidental clicks. Your feedback helps us understand which topics
resonate most.