Background: The Temptation of “Quick‑And‑Dirty” Secrets

Many teams accelerate delivery by embedding an SSH private key directly into a CI/CD job definition. The key is placed in a plain‑text environment variable, referenced by a deploy script, and the pipeline runs without a hitch. On the surface this works, but the practice creates a silent attack surface that can be exploited by a single misconfigured runner or a compromised build artifact.

The Naïve Implementation

Below is a minimal GitHub Actions workflow that demonstrates the insecure pattern. The private key is stored in the SSH_KEY secret, but the secret value itself is a raw PEM file that is written to disk without any additional protection.

name: Deploy‑to‑Prod
on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu‑latest
    env:
      SSH_KEY: ${{ secrets.SSH_KEY }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Write SSH key to file
        run: |
          echo "$SSH_KEY" > /tmp/deploy_key
          chmod 600 /tmp/deploy_key

      - name: Deploy over SSH
        run: |
          ssh -i /tmp/deploy_key [email protected] \
            "cd /var/www && git pull && systemctl restart app"

The workflow appears functional, yet it contains three critical flaws:

  • Plain‑text persistence: The key is written to the runner’s file system, where any other step or a malicious actor with runner access can read it.
  • Unrestricted propagation: If a later step runs a Docker container, the key may be inherited into the container’s environment.
  • Static secret lifecycle: Rotating the key requires manual updates to the GitHub secret and a new pipeline run, increasing operational overhead.

Hidden Risks Behind the Scenes

Attackers who gain a foothold on a shared runner can harvest /tmp/deploy_key and use it to pivot into production environments. In multi‑tenant CI services, a rogue job from a different repository can enumerate the file system of sibling jobs, especially when the runner reuses the same VM. Moreover, audit logs often omit file‑system reads, making post‑mortem forensics difficult.

The risk is amplified when the same key is reused across multiple environments (staging, production, QA). Compromise of a single runner therefore grants access to every environment that trusts the key.

Secure Alternative: Vault‑Backed Dynamic SSH Keys

HashiCorp Vault can generate short‑lived, dynamically‑signed SSH certificates that are bound to a specific host and time window. The CI job requests a certificate at runtime, uses it for the deployment, and discards it automatically. No private key ever touches the runner’s persistent storage.

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

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

      - name: Install Vault CLI
        run: |
          curl -fsSL https://releases.hashicorp.com/vault/1.15.0/vault_1.15.0_linux_amd64.zip -o vault.zip
          unzip vault.zip
          sudo mv vault /usr/local/bin/

      - name: Authenticate to Vault
        env:
          VAULT_ADDR: ${{ secrets.VAULT_ADDR }}
          VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }}
        run: |
          vault login $VAULT_TOKEN

      - name: Request SSH certificate
        env:
          VAULT_ROLE: deployer-role
        run: |
          cert=$(vault write -field=certificate ssh-client-signer/sign/${VAULT_ROLE} \
            public_key=@${HOME}/.ssh/id_rsa.pub \
            ttl=10m)
          echo "$cert" > /tmp/ssh_cert

      - name: Deploy with certificate
        run: |
          ssh -i /tmp/ssh_cert -o "IdentitiesOnly=yes" \
            -o "UserKnownHostsFile=/dev/null" -o "StrictHostKeyChecking=no" \
            [email protected] \
            "cd /var/www && git pull && systemctl restart app"

In this flow:

  • Vault issues a one‑time certificate bound to the deployer-role and a ten‑minute TTL.
  • The certificate is stored only in a temporary file that the job deletes when it exits.
  • Rotation is automatic—each run receives a fresh certificate, eliminating stale credentials.

Implementation Details Worth Noticing

1. SSH Public Key Management: The CI runner must have a static public key (~/.ssh/id_rsa.pub) that Vault uses to sign a certificate. The corresponding private key never leaves the runner.

2. Vault Policies: Create a minimal policy that allows the CI service account to sign certificates only for the intended host pattern:

# vault policy file: ci-deploy.hcl
path "ssh-client-signer/sign/deployer-role" {
  capabilities = ["update"]
}

3. Audit Logging: Enable Vault audit devices (file or syslog) to capture every certificate request, providing traceability that static environment variables cannot offer.

Security and Best Practices

Never write private keys to disk: If a key must be used, pipe it directly into the SSH command via a file descriptor or ssh -i /dev/stdin and delete the descriptor immediately after use.
Prefer short‑lived credentials: Dynamic secrets reduce the window of exposure.
Restrict CI runner permissions: Run jobs under a non‑root user and limit filesystem access with container‑based isolation.
Rotate Vault tokens regularly: Use GitHub OIDC integration to obtain short‑lived Vault tokens instead of static VAULT_TOKEN secrets.
Monitor for abnormal SSH certificate requests: Alert on spikes in certificate issuance that could indicate credential‑theft attempts.

“A secret that lives longer than it needs to is a secret that invites compromise.” – Security Engineering Principle

Conclusion

Embedding SSH private keys in CI/CD environment variables provides a convenient shortcut, but it creates a hidden liability that can be exploited with minimal effort. By swapping static keys for Vault‑generated, short‑lived SSH certificates, teams gain automatic rotation, fine‑grained auditability, and a dramatically reduced attack surface. The extra steps required to integrate a secret‑management system pay off in resilience and compliance, especially for organizations that must demonstrate rigorous control over production access.

The transition is straightforward: replace the plain‑text secret with a Vault‑backed request, tighten IAM policies, and enforce short‑lived credentials. Once in place, the pipeline remains fast, the code stays clean, and the security posture improves without sacrificing developer velocity.