Stop Storing SSH Keys in Your CI/CD Secrets. They Are Already Stolen

TL;DR: Long-lived SSH keys in pipeline variables are the new hardcoded password. Short-lived, IdP-brokered SSH sessions through Twingate Privileged Access for SSH eliminate the stored key entirely.
If you deploy over SSH from a CI/CD pipeline, there is almost certainly a private key sitting in a variable named DEPLOY_SSH_KEY or PROD_SSH_PRIVATE_KEY somewhere in your organization.
It was probably generated years ago, has never been rotated, and at least three former employees know it exists. In September 2025, the Shai-Hulud worm compromised hundreds of npm packages and, according to Wiz's incident analysis, 77% of the machines it landed on were CI/CD runners.
That is not a coincidence. Runners are where the keys live.
The dirty secret of every deploy pipeline
Most teams built their deploy pipelines the same way. Someone generated an SSH keypair on their laptop, added the public key to the target servers' authorized_keys, and pasted the private key into GitHub Actions secrets or GitLab CI variables. It worked. Nobody touched it again.
That was fine when pipelines ran on trusted hardware in a corner of the office. It stopped being fine a while ago. The 2026 GitGuardian State of Secrets Sprawl report found 28.65 million new hardcoded secrets on public GitHub in 2025, a 34% year-over-year jump. It also found that 64% of secrets confirmed valid in 2022 were still active four years later.
Nobody rotates the deploy key. Ever.
The problem is not that developers are careless, but that the credential model is wrong. A long-lived SSH private key stored as an environment variable is:
Copyable by any workflow step, including malicious ones injected via a compromised dependency
Loggable if a
set -xslips into a shell scriptExtractable by anyone with repo admin (which usually includes a lot of people)
Impossible to attribute in a breach because every deploy uses the same identity
Effectively permanent because rotating it means coordinating with every server it touches
Shai-Hulud, TJ-Actions, the pull_request_target abuses that keep resurfacing — the pattern is consistent. Attackers do not need to break your servers. They need to break your pipeline once and exfiltrate the secrets.
The rest of the industry moved on. SSH did not.
Cloud providers figured this out.
If you deploy to AWS from GitHub Actions in 2026 and you are still storing AWS_SECRET_ACCESS_KEY in your repo secrets, your security team is going to have questions.
The correct pattern is OIDC federation: GitHub signs a short-lived token asserting this workflow, in this repo, on this branch, at this time, and AWS STS exchanges it for temporary credentials scoped to a specific IAM role. No stored key. No rotation. Full audit trail tied to a workflow run, not an anonymous service account.
That pattern exists for AWS, GCP, Azure, HashiCorp Vault, Google Cloud, Snowflake, and probably a dozen others by the time you read this. GitHub's own documentation on OIDC has been strongly recommending it since 2021.
SSH deployment access never got this treatment.
If you SSH into an EC2 instance, a bare-metal server, a Raspberry Pi in a colo, a legacy database host, or anything else that speaks port 22, you are almost certainly still using the same static keypair you generated in 2019.
The keyless pattern that everyone agrees is the right shape for cloud APIs has not extended to the actual servers those APIs deploy to.
That gap is what attackers are working right now.
What "keyless SSH" actually looks like
The property you want is straightforward: the CI/CD pipeline should authenticate as itself, through your identity provider, and receive access to exactly the SSH targets it needs, for exactly the duration of the deploy job. No stored private key. No shared credential. Session ends, access ends.
Here is the shape:
The pipeline runs under a service account defined in your IdP (Okta, Entra ID, Google Workspace, JumpCloud, whatever you use).
When the deploy step starts, the pipeline authenticates to the IdP using a workload identity federation flow, the same OIDC pattern used for cloud APIs.
The authentication produces a signed assertion that a specific pipeline, on a specific repo and branch, is requesting access.
A broker sits between the pipeline and the SSH target. It checks the assertion against a policy: is this service account allowed to reach this host, on this port, right now?
If allowed, the broker establishes a short-lived, authenticated SSH session. The pipeline never sees a private key.
When the job finishes, the session terminates. There is nothing left to steal.
The critical property is that no long-lived credential lives on the runner.
If Shai-Hulud 3 lands on your GitHub Actions runner tomorrow and dumps every environment variable it can find, it gets nothing useful for SSH access. The identity is federated, the session is brokered, and both die when the job dies.
How Twingate does this
Twingate Privileged Access for SSH is the broker in the model above. It operates as a Layer 7 gateway that authenticates every SSH (and HTTPS, and database) session against your IdP and enforces access policy per resource, per identity, per session.
The flow for a CI/CD service account looks like this:
Create a service account in Twingate tied to your IdP.
Assign the service account a policy and define which Resources (specific hosts, ports, or subnets) it can reach.
In your pipeline, authenticate the runner as the service account. The runner receives a short-lived credential.
The pipeline opens an SSH connection to the target hostname. The Twingate Client on the runner intercepts the DNS query, requests authorization from the Controller, and (if the policy allows) establishes an encrypted tunnel to the Connector inside your network.
The Connector independently verifies the ACL and proxies the SSH traffic to the target host.
The session is logged with the identity of the service account, the resource accessed, the workflow that initiated it, and the duration.
A few properties fall out of this architecture that matter for supply chain risk:
No stored SSH private key. Session credentials are short-lived and issued per session. There is nothing to exfiltrate from
~/.ssh/or from CI/CD secrets that grants persistent access.No exposed port 22. The Connector makes only outbound connections. Your SSH targets sit behind a firewall with no inbound rules. The internet does not know they exist.
Independent verification at the Connector. Even if the Controller is compromised, the Connector re-checks the ACL against the request. A single-point compromise does not grant access.
Full session audit. All SSH sessions are automatically recorded and tied to the identity, not an anonymous shared key. Logs are exported to
stderron the Twingate Gateway in asciicast v2 format and are stored entirely within your infrastructure.Least privilege by default. A service account that deploys to
prod-web-01cannot reachprod-db-01unless policy explicitly allows it. Compare that to a static SSH key that typically has access to everything the user who created it had access to.
What this looks like in a pipeline
The concrete difference in a GitHub Actions workflow is that the deploy job no longer references a stored private key. It authenticates the runner as the service account and runs the SSH command normally.
Authenticate the runner as a Twingate service account and open an SSH session that never sees a stored key:
name: Deploy
on:
push:
branches: [main]
The TWINGATE_SERVICE_KEY here is a scoped credential for the Twingate service account, not an SSH private key. It grants the runner the ability to authenticate to Twingate and establish sessions the policy permits, and nothing else. Rotate it, and you have rotated exactly one thing in one place. No authorized_keys files to update on 40 servers.
For a walkthrough of setting this up end to end, see the Twingate service accounts documentation.
What you should do this quarter
If you have a CI/CD pipeline that SSHes into anything, the honest checklist is:
Grep your repo secrets, GitLab variables, and any secret manager for anything matching
SSH_KEY,PRIVATE_KEY,DEPLOY_KEY,PEM. Count how many you find. Count how many you can attribute to a specific owner. Count how many you can prove were rotated in the last 12 months. That number will be bad.Assume every one of those keys is already compromised. Not because of any specific breach, but because the base rate of runner compromise is now high enough that it is the safer working assumption.
Pick one deploy path — ideally your riskiest one, whichever pipeline has the most direct route to production — and cut it over to brokered SSH. Prove the pattern works.
Roll the pattern out. Delete the stored keys. Remove the corresponding entries from
authorized_keys.When someone asks why the deploy is faster and the security review is shorter, tell them.
The reason to do this is not that keyless SSH is fashionable. It is that the specific asset attackers are hunting, long-lived credentials on CI/CD runners, should not exist in your environment.
If there is no key to steal, the attack does not work.
Closing
For a walkthrough of Privileged Access for SSH, service accounts, and session logging capabilities referenced here, see the Twingate documentation.
New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.
Rapidly implement a modern Zero Trust network that is more secure and maintainable than VPNs.
Stop Storing SSH Keys in Your CI/CD Secrets. They Are Already Stolen

TL;DR: Long-lived SSH keys in pipeline variables are the new hardcoded password. Short-lived, IdP-brokered SSH sessions through Twingate Privileged Access for SSH eliminate the stored key entirely.
If you deploy over SSH from a CI/CD pipeline, there is almost certainly a private key sitting in a variable named DEPLOY_SSH_KEY or PROD_SSH_PRIVATE_KEY somewhere in your organization.
It was probably generated years ago, has never been rotated, and at least three former employees know it exists. In September 2025, the Shai-Hulud worm compromised hundreds of npm packages and, according to Wiz's incident analysis, 77% of the machines it landed on were CI/CD runners.
That is not a coincidence. Runners are where the keys live.
The dirty secret of every deploy pipeline
Most teams built their deploy pipelines the same way. Someone generated an SSH keypair on their laptop, added the public key to the target servers' authorized_keys, and pasted the private key into GitHub Actions secrets or GitLab CI variables. It worked. Nobody touched it again.
That was fine when pipelines ran on trusted hardware in a corner of the office. It stopped being fine a while ago. The 2026 GitGuardian State of Secrets Sprawl report found 28.65 million new hardcoded secrets on public GitHub in 2025, a 34% year-over-year jump. It also found that 64% of secrets confirmed valid in 2022 were still active four years later.
Nobody rotates the deploy key. Ever.
The problem is not that developers are careless, but that the credential model is wrong. A long-lived SSH private key stored as an environment variable is:
Copyable by any workflow step, including malicious ones injected via a compromised dependency
Loggable if a
set -xslips into a shell scriptExtractable by anyone with repo admin (which usually includes a lot of people)
Impossible to attribute in a breach because every deploy uses the same identity
Effectively permanent because rotating it means coordinating with every server it touches
Shai-Hulud, TJ-Actions, the pull_request_target abuses that keep resurfacing — the pattern is consistent. Attackers do not need to break your servers. They need to break your pipeline once and exfiltrate the secrets.
The rest of the industry moved on. SSH did not.
Cloud providers figured this out.
If you deploy to AWS from GitHub Actions in 2026 and you are still storing AWS_SECRET_ACCESS_KEY in your repo secrets, your security team is going to have questions.
The correct pattern is OIDC federation: GitHub signs a short-lived token asserting this workflow, in this repo, on this branch, at this time, and AWS STS exchanges it for temporary credentials scoped to a specific IAM role. No stored key. No rotation. Full audit trail tied to a workflow run, not an anonymous service account.
That pattern exists for AWS, GCP, Azure, HashiCorp Vault, Google Cloud, Snowflake, and probably a dozen others by the time you read this. GitHub's own documentation on OIDC has been strongly recommending it since 2021.
SSH deployment access never got this treatment.
If you SSH into an EC2 instance, a bare-metal server, a Raspberry Pi in a colo, a legacy database host, or anything else that speaks port 22, you are almost certainly still using the same static keypair you generated in 2019.
The keyless pattern that everyone agrees is the right shape for cloud APIs has not extended to the actual servers those APIs deploy to.
That gap is what attackers are working right now.
What "keyless SSH" actually looks like
The property you want is straightforward: the CI/CD pipeline should authenticate as itself, through your identity provider, and receive access to exactly the SSH targets it needs, for exactly the duration of the deploy job. No stored private key. No shared credential. Session ends, access ends.
Here is the shape:
The pipeline runs under a service account defined in your IdP (Okta, Entra ID, Google Workspace, JumpCloud, whatever you use).
When the deploy step starts, the pipeline authenticates to the IdP using a workload identity federation flow, the same OIDC pattern used for cloud APIs.
The authentication produces a signed assertion that a specific pipeline, on a specific repo and branch, is requesting access.
A broker sits between the pipeline and the SSH target. It checks the assertion against a policy: is this service account allowed to reach this host, on this port, right now?
If allowed, the broker establishes a short-lived, authenticated SSH session. The pipeline never sees a private key.
When the job finishes, the session terminates. There is nothing left to steal.
The critical property is that no long-lived credential lives on the runner.
If Shai-Hulud 3 lands on your GitHub Actions runner tomorrow and dumps every environment variable it can find, it gets nothing useful for SSH access. The identity is federated, the session is brokered, and both die when the job dies.
How Twingate does this
Twingate Privileged Access for SSH is the broker in the model above. It operates as a Layer 7 gateway that authenticates every SSH (and HTTPS, and database) session against your IdP and enforces access policy per resource, per identity, per session.
The flow for a CI/CD service account looks like this:
Create a service account in Twingate tied to your IdP.
Assign the service account a policy and define which Resources (specific hosts, ports, or subnets) it can reach.
In your pipeline, authenticate the runner as the service account. The runner receives a short-lived credential.
The pipeline opens an SSH connection to the target hostname. The Twingate Client on the runner intercepts the DNS query, requests authorization from the Controller, and (if the policy allows) establishes an encrypted tunnel to the Connector inside your network.
The Connector independently verifies the ACL and proxies the SSH traffic to the target host.
The session is logged with the identity of the service account, the resource accessed, the workflow that initiated it, and the duration.
A few properties fall out of this architecture that matter for supply chain risk:
No stored SSH private key. Session credentials are short-lived and issued per session. There is nothing to exfiltrate from
~/.ssh/or from CI/CD secrets that grants persistent access.No exposed port 22. The Connector makes only outbound connections. Your SSH targets sit behind a firewall with no inbound rules. The internet does not know they exist.
Independent verification at the Connector. Even if the Controller is compromised, the Connector re-checks the ACL against the request. A single-point compromise does not grant access.
Full session audit. All SSH sessions are automatically recorded and tied to the identity, not an anonymous shared key. Logs are exported to
stderron the Twingate Gateway in asciicast v2 format and are stored entirely within your infrastructure.Least privilege by default. A service account that deploys to
prod-web-01cannot reachprod-db-01unless policy explicitly allows it. Compare that to a static SSH key that typically has access to everything the user who created it had access to.
What this looks like in a pipeline
The concrete difference in a GitHub Actions workflow is that the deploy job no longer references a stored private key. It authenticates the runner as the service account and runs the SSH command normally.
Authenticate the runner as a Twingate service account and open an SSH session that never sees a stored key:
name: Deploy
on:
push:
branches: [main]
The TWINGATE_SERVICE_KEY here is a scoped credential for the Twingate service account, not an SSH private key. It grants the runner the ability to authenticate to Twingate and establish sessions the policy permits, and nothing else. Rotate it, and you have rotated exactly one thing in one place. No authorized_keys files to update on 40 servers.
For a walkthrough of setting this up end to end, see the Twingate service accounts documentation.
What you should do this quarter
If you have a CI/CD pipeline that SSHes into anything, the honest checklist is:
Grep your repo secrets, GitLab variables, and any secret manager for anything matching
SSH_KEY,PRIVATE_KEY,DEPLOY_KEY,PEM. Count how many you find. Count how many you can attribute to a specific owner. Count how many you can prove were rotated in the last 12 months. That number will be bad.Assume every one of those keys is already compromised. Not because of any specific breach, but because the base rate of runner compromise is now high enough that it is the safer working assumption.
Pick one deploy path — ideally your riskiest one, whichever pipeline has the most direct route to production — and cut it over to brokered SSH. Prove the pattern works.
Roll the pattern out. Delete the stored keys. Remove the corresponding entries from
authorized_keys.When someone asks why the deploy is faster and the security review is shorter, tell them.
The reason to do this is not that keyless SSH is fashionable. It is that the specific asset attackers are hunting, long-lived credentials on CI/CD runners, should not exist in your environment.
If there is no key to steal, the attack does not work.
Closing
For a walkthrough of Privileged Access for SSH, service accounts, and session logging capabilities referenced here, see the Twingate documentation.
New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.
Rapidly implement a modern Zero Trust network that is more secure and maintainable than VPNs.
Stop Storing SSH Keys in Your CI/CD Secrets. They Are Already Stolen

TL;DR: Long-lived SSH keys in pipeline variables are the new hardcoded password. Short-lived, IdP-brokered SSH sessions through Twingate Privileged Access for SSH eliminate the stored key entirely.
If you deploy over SSH from a CI/CD pipeline, there is almost certainly a private key sitting in a variable named DEPLOY_SSH_KEY or PROD_SSH_PRIVATE_KEY somewhere in your organization.
It was probably generated years ago, has never been rotated, and at least three former employees know it exists. In September 2025, the Shai-Hulud worm compromised hundreds of npm packages and, according to Wiz's incident analysis, 77% of the machines it landed on were CI/CD runners.
That is not a coincidence. Runners are where the keys live.
The dirty secret of every deploy pipeline
Most teams built their deploy pipelines the same way. Someone generated an SSH keypair on their laptop, added the public key to the target servers' authorized_keys, and pasted the private key into GitHub Actions secrets or GitLab CI variables. It worked. Nobody touched it again.
That was fine when pipelines ran on trusted hardware in a corner of the office. It stopped being fine a while ago. The 2026 GitGuardian State of Secrets Sprawl report found 28.65 million new hardcoded secrets on public GitHub in 2025, a 34% year-over-year jump. It also found that 64% of secrets confirmed valid in 2022 were still active four years later.
Nobody rotates the deploy key. Ever.
The problem is not that developers are careless, but that the credential model is wrong. A long-lived SSH private key stored as an environment variable is:
Copyable by any workflow step, including malicious ones injected via a compromised dependency
Loggable if a
set -xslips into a shell scriptExtractable by anyone with repo admin (which usually includes a lot of people)
Impossible to attribute in a breach because every deploy uses the same identity
Effectively permanent because rotating it means coordinating with every server it touches
Shai-Hulud, TJ-Actions, the pull_request_target abuses that keep resurfacing — the pattern is consistent. Attackers do not need to break your servers. They need to break your pipeline once and exfiltrate the secrets.
The rest of the industry moved on. SSH did not.
Cloud providers figured this out.
If you deploy to AWS from GitHub Actions in 2026 and you are still storing AWS_SECRET_ACCESS_KEY in your repo secrets, your security team is going to have questions.
The correct pattern is OIDC federation: GitHub signs a short-lived token asserting this workflow, in this repo, on this branch, at this time, and AWS STS exchanges it for temporary credentials scoped to a specific IAM role. No stored key. No rotation. Full audit trail tied to a workflow run, not an anonymous service account.
That pattern exists for AWS, GCP, Azure, HashiCorp Vault, Google Cloud, Snowflake, and probably a dozen others by the time you read this. GitHub's own documentation on OIDC has been strongly recommending it since 2021.
SSH deployment access never got this treatment.
If you SSH into an EC2 instance, a bare-metal server, a Raspberry Pi in a colo, a legacy database host, or anything else that speaks port 22, you are almost certainly still using the same static keypair you generated in 2019.
The keyless pattern that everyone agrees is the right shape for cloud APIs has not extended to the actual servers those APIs deploy to.
That gap is what attackers are working right now.
What "keyless SSH" actually looks like
The property you want is straightforward: the CI/CD pipeline should authenticate as itself, through your identity provider, and receive access to exactly the SSH targets it needs, for exactly the duration of the deploy job. No stored private key. No shared credential. Session ends, access ends.
Here is the shape:
The pipeline runs under a service account defined in your IdP (Okta, Entra ID, Google Workspace, JumpCloud, whatever you use).
When the deploy step starts, the pipeline authenticates to the IdP using a workload identity federation flow, the same OIDC pattern used for cloud APIs.
The authentication produces a signed assertion that a specific pipeline, on a specific repo and branch, is requesting access.
A broker sits between the pipeline and the SSH target. It checks the assertion against a policy: is this service account allowed to reach this host, on this port, right now?
If allowed, the broker establishes a short-lived, authenticated SSH session. The pipeline never sees a private key.
When the job finishes, the session terminates. There is nothing left to steal.
The critical property is that no long-lived credential lives on the runner.
If Shai-Hulud 3 lands on your GitHub Actions runner tomorrow and dumps every environment variable it can find, it gets nothing useful for SSH access. The identity is federated, the session is brokered, and both die when the job dies.
How Twingate does this
Twingate Privileged Access for SSH is the broker in the model above. It operates as a Layer 7 gateway that authenticates every SSH (and HTTPS, and database) session against your IdP and enforces access policy per resource, per identity, per session.
The flow for a CI/CD service account looks like this:
Create a service account in Twingate tied to your IdP.
Assign the service account a policy and define which Resources (specific hosts, ports, or subnets) it can reach.
In your pipeline, authenticate the runner as the service account. The runner receives a short-lived credential.
The pipeline opens an SSH connection to the target hostname. The Twingate Client on the runner intercepts the DNS query, requests authorization from the Controller, and (if the policy allows) establishes an encrypted tunnel to the Connector inside your network.
The Connector independently verifies the ACL and proxies the SSH traffic to the target host.
The session is logged with the identity of the service account, the resource accessed, the workflow that initiated it, and the duration.
A few properties fall out of this architecture that matter for supply chain risk:
No stored SSH private key. Session credentials are short-lived and issued per session. There is nothing to exfiltrate from
~/.ssh/or from CI/CD secrets that grants persistent access.No exposed port 22. The Connector makes only outbound connections. Your SSH targets sit behind a firewall with no inbound rules. The internet does not know they exist.
Independent verification at the Connector. Even if the Controller is compromised, the Connector re-checks the ACL against the request. A single-point compromise does not grant access.
Full session audit. All SSH sessions are automatically recorded and tied to the identity, not an anonymous shared key. Logs are exported to
stderron the Twingate Gateway in asciicast v2 format and are stored entirely within your infrastructure.Least privilege by default. A service account that deploys to
prod-web-01cannot reachprod-db-01unless policy explicitly allows it. Compare that to a static SSH key that typically has access to everything the user who created it had access to.
What this looks like in a pipeline
The concrete difference in a GitHub Actions workflow is that the deploy job no longer references a stored private key. It authenticates the runner as the service account and runs the SSH command normally.
Authenticate the runner as a Twingate service account and open an SSH session that never sees a stored key:
name: Deploy
on:
push:
branches: [main]
The TWINGATE_SERVICE_KEY here is a scoped credential for the Twingate service account, not an SSH private key. It grants the runner the ability to authenticate to Twingate and establish sessions the policy permits, and nothing else. Rotate it, and you have rotated exactly one thing in one place. No authorized_keys files to update on 40 servers.
For a walkthrough of setting this up end to end, see the Twingate service accounts documentation.
What you should do this quarter
If you have a CI/CD pipeline that SSHes into anything, the honest checklist is:
Grep your repo secrets, GitLab variables, and any secret manager for anything matching
SSH_KEY,PRIVATE_KEY,DEPLOY_KEY,PEM. Count how many you find. Count how many you can attribute to a specific owner. Count how many you can prove were rotated in the last 12 months. That number will be bad.Assume every one of those keys is already compromised. Not because of any specific breach, but because the base rate of runner compromise is now high enough that it is the safer working assumption.
Pick one deploy path — ideally your riskiest one, whichever pipeline has the most direct route to production — and cut it over to brokered SSH. Prove the pattern works.
Roll the pattern out. Delete the stored keys. Remove the corresponding entries from
authorized_keys.When someone asks why the deploy is faster and the security review is shorter, tell them.
The reason to do this is not that keyless SSH is fashionable. It is that the specific asset attackers are hunting, long-lived credentials on CI/CD runners, should not exist in your environment.
If there is no key to steal, the attack does not work.
Closing
For a walkthrough of Privileged Access for SSH, service accounts, and session logging capabilities referenced here, see the Twingate documentation.
New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.
Solutions
Solutions
The VPN replacement your workforce will love.
Solutions