Introduction – The hidden liability of permanent keys

CI/CD pipelines are the backbone of modern software delivery, but they also become an attractive target when they contain long‑lived SSH private keys. A leaked key can give an attacker unrestricted access to production servers, bypassing network‑level firewalls and audit trails. The problem is not just the key itself; it is the fact that most pipelines treat the key as a permanent credential, stored in plain text or encrypted secrets that are rarely rotated. This article explains why that approach is fundamentally insecure and demonstrates a safer alternative: short‑lived SSH certificates issued by a dedicated Certificate Authority (CA).

Why static keys are a security anti‑pattern

Unlimited lifespan. A private key does not expire unless you manually revoke it. If the key is compromised, the window of exposure can stretch for months or years.

Credential sprawl. When the same key is reused across multiple jobs, environments, and repositories, a single breach cascades through the entire delivery chain.

Audit difficulty. Traditional SSH logging shows the username, not the originating CI job. Distinguishing a legitimate automation event from a malicious one becomes a guessing game.

# Example of a vulnerable GitHub Actions workflow
name: Deploy
on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install SSH key
        run: |
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
      - name: Deploy to prod
        run: |
          ssh -o StrictHostKeyChecking=no [email protected] 'sudo systemctl restart app'

The snippet above stores a private key in secrets.SSH_PRIVATE_KEY. Even though GitHub encrypts the secret at rest, the key is loaded into the runner’s file system in clear text, and it persists for the duration of the job. If an attacker gains read access to the runner (through a vulnerable action or a mis‑configured container), they can extract the key and reuse it indefinitely.

Enter SSH certificates – Short‑lived, auditable, and revocable

An SSH certificate is a signed statement that binds a public key to a principal (username) and an expiration time. The CA’s private key never leaves a hardened host, and certificates can be generated on demand for each pipeline run. Because each certificate is time‑boxed (often to a few minutes), the impact of a leak is dramatically reduced.

# Minimal OpenSSH CA setup (run on a secure bastion)
# 1. Generate the CA key pair (keep this offline!)
ssh-keygen -t ed25519 -f /etc/ssh/ca_key -N ""

# 2. Distribute the CA public key to all target servers
cat /etc/ssh/ca_key.pub >> /etc/ssh/sshd_config
# Add the following line to /etc/ssh/sshd_config on each host:
#   TrustedUserCAKeys /etc/ssh/ca_key.pub
systemctl restart sshd

With the CA in place, the CI runner can request a short‑lived certificate for a temporary key pair. The workflow below illustrates how to generate a one‑minute certificate using ssh-keygen and ssh-keysign on the CA host, then use it for a remote command.

Step‑by‑step tutorial – Replacing static keys with certificates

Prerequisite: A secure machine that runs the CA commands (e.g., a dedicated VM with restricted network access). The CI job will invoke this machine via a thin API (HTTPS) that returns a signed certificate.

# 1. API endpoint on the CA host (Python Flask example)
from flask import Flask, request, jsonify
import subprocess
import os, datetime

app = Flask(__name__)

CA_KEY = "/etc/ssh/ca_key"
CERT_DIR = "/tmp/certs"

@app.route('/sign', methods=['POST'])
def sign():
    username = request.json.get('user')
    if not username:
        return jsonify({'error': 'user missing'}), 400

    # Generate temporary key pair
    key_path = f"{CERT_DIR}/{username}_key"
    subprocess.run(["ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", ""], check=True)

    # Sign the public key, valid for 60 seconds
    cert_path = f"{key_path}-cert.pub"
    valid_until = (datetime.datetime.utcnow() + datetime.timedelta(seconds=60)).strftime('%Y%m%d%H%M%S')
    subprocess.run([
        "ssh-keygen", "-s", CA_KEY,
        "-I", f"{username}@ci-{int(datetime.datetime.utcnow().timestamp())}",
        "-V", f"+0s:{valid_until}",
        "-n", username,
        f"{key_path}.pub"
    ], check=True)

    with open(key_path, 'rb') as pk, open(cert_path, 'rb') as cert:
        return jsonify({
            'private_key': pk.read().decode(),
            'certificate': cert.read().decode()
        })

if __name__ == '__main__':
    os.makedirs(CERT_DIR, exist_ok=True)
    app.run(host='0.0.0.0', port=5000)

The Flask service creates a disposable key pair, signs the public key with the CA, and returns both the private key and its certificate. Because the private key exists only in memory on the CI runner and expires after 60 seconds, the attack surface is dramatically reduced.

# 2. GitHub Actions workflow that consumes the certificate
name: Secure Deploy
on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      CA_API: https://ca.example.com:5000/sign
    steps:
      - uses: actions/checkout@v3

      - name: Obtain short‑lived certificate
        id: cert
        run: |
          RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" \
            -d '{"user":"ci-deployer"}' ${{ env.CA_API }})
          echo "$RESPONSE" | jq -r '.private_key' > ~/.ssh/id_rsa
          echo "$RESPONSE" | jq -r '.certificate' > ~/.ssh/id_rsa-cert.pub
          chmod 600 ~/.ssh/id_rsa

      - name: Deploy using certificate
        run: |
          ssh -i ~/.ssh/id_rsa -o CertificateFile=~/.ssh/id_rsa-cert.pub \
            -o StrictHostKeyChecking=no [email protected] \
            'sudo systemctl restart app'

Notice the use of -o CertificateFile=~/.ssh/id_rsa-cert.pub. The SSH client automatically presents the certificate alongside the private key. If the certificate is expired, the connection fails, forcing the pipeline to request a fresh one.

Security and Best Practices

Restrict CA access. The signing API should be reachable only from trusted CI IP ranges and must enforce mutual TLS to prevent unauthorized certificate issuance.

Audit certificate usage. Enable LogLevel VERBOSE on the target SSH daemon to record the certificate serial number and the valid after / valid before timestamps. This provides a tamper‑evident trail linking each deployment to a specific CI run.

Rotate the CA key regularly. Treat the CA private key like a root certificate: store it in an HSM or a hardware‑backed key store and rotate it at least annually.

“A short‑lived SSH certificate is to a static key what a one‑time password is to a static password – it limits the window of opportunity for an attacker.”

Conclusion – From static keys to disposable credentials

Storing permanent SSH private keys in CI/CD pipelines is a hidden liability that undermines the very automation it is meant to enable. By introducing a lightweight CA service and using short‑lived SSH certificates, teams gain fine‑grained auditability, automatic expiration, and a clear revocation path. The code examples above show that the transition requires only a few lines of configuration, yet the security payoff is substantial.

Adopt this pattern today, and you will eliminate a whole class of credential leaks while preserving the speed and reliability of your deployment pipelines.