How actions/checkout Manages and Secures SSH Keys in GitHub Actions
The actions/checkout action secures SSH keys by writing them to temporary files with restrictive 600 permissions (or Windows ACLs), injecting them into a custom GIT_SSH_COMMAND environment variable, and automatically destroying all credential files and Git configuration during the POST cleanup phase.
The official actions/checkout action supports cloning private repositories via SSH through four dedicated inputs—ssh-key, ssh-known-hosts, ssh-strict, and ssh-user. Understanding how this process handles sensitive private key material is critical for securing CI/CD pipelines. This article traces the complete lifecycle of SSH credentials through the action's TypeScript source code, from initial input validation in src/input-helper.ts to guaranteed removal during the post-run cleanup.
SSH Authentication Inputs and Configuration
SSH authentication is triggered when you provide the ssh-key input in your workflow. The action collects four related inputs in src/input-helper.ts (lines 60-64) and stores them in a GitSourceSettings object defined in src/git-source-settings.ts.
The supported inputs are:
ssh-key– The raw private key material (typically fromsecrets.SSH_PRIVATE_KEY)ssh-known-hosts– Additional known hosts entries to trustssh-strict– Whether to enable strict host key checking (defaults totrue)ssh-user– The SSH username (defaults togit)
When configureAuth() is invoked during checkout, the action creates a GitAuthHelper instance in src/git-auth-helper.ts (lines 27-32). This helper orchestrates the secure handling of credentials throughout the job lifecycle.
Secure Key Storage in Temporary Files
The configureSsh() method in src/git-auth-helper.ts handles the physical storage of your private key. Rather than persisting credentials to disk permanently, the action creates ephemeral files in the runner's temporary directory ($RUNNER_TEMP).
On Unix systems, the key is written with mode 0o600 (read/write for owner only):
// src/git-auth-helper.ts, lines 55-66
await fs.promises.writeFile(
this.sshKeyPath,
this.settings.sshKey.trim() + '\n',
{mode: 0o600}
);
On Windows, the action explicitly restricts access using icacls.exe to remove inherited permissions and grant access only to the current user (lines 68-75). This ensures the private key is never world-readable, even on Windows runners where POSIX permissions don't apply.
The paths to these temporary files are immediately saved to the action state via src/state-helper.ts (lines 19-22) using saveState():
// State variables stored for POST phase cleanup
export const SshKeyPath = 'sshKeyPath'
export const SshKnownHostsPath = 'sshKnownHostsPath'
Known Hosts Verification and MITM Protection
Before constructing the SSH command, the action builds a comprehensive known-hosts file to prevent man-in-the-middle attacks. In src/git-auth-helper.ts (lines 78-96), the configureSsh() method concatenates three sources:
- Existing user known hosts – Contents of
~/.ssh/known_hostsif it exists - Input-provided hosts – Entries from the
ssh-known-hostsinput - Hard-coded GitHub host keys – A hardcoded set of GitHub.com SSH host keys injected automatically
This composite file is written adjacent to the temporary key file, and its location is stored in the action state (lines 97-100). By default, the action enforces strict host key checking unless disabled.
Environment Variable and Git Configuration
With the temporary files secured, the action constructs a custom GIT_SSH_COMMAND environment variable. In src/git-auth-helper.ts (lines 101-113), the command is built to reference the temporary key and known-hosts files:
// Command construction with strict checking
this.sshCommand = `"${sshPath}" -i "${this.sshKeyPath}"`;
if (this.settings.sshStrict) {
this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no';
}
this.sshCommand += ` -o "UserKnownHostsFile=${this.sshKnownHostsPath}"`;
this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand);
If persist-credentials is set to true, the action persists this configuration into the repository's local Git config under core.sshCommand (lines 115-118). This allows subsequent Git operations—such as submodule updates or fetch commands—to reuse the same SSH authentication without requiring the key to be exposed again:
if (this.settings.persistCredentials) {
await this.git.config('core.sshCommand', this.sshCommand);
}
Automatic Cleanup in the POST Phase
Security guarantees are fulfilled during the POST phase when removeAuth() calls removeSsh() in src/git-auth-helper.ts (lines 36-46). This method retrieves the temporary file paths from the action state (saved earlier via state-helper.ts) and performs guaranteed cleanup:
// POST cleanup removes all credential material
await io.rmRF(this.sshKeyPath || stateHelper.SshKeyPath);
await io.rmRF(this.sshKnownHostsPath || stateHelper.SshKnownHostsPath);
await this.removeGitConfig('core.sshCommand');
await this.removeSubmoduleGitConfig('core.sshCommand');
This ensures no private key material persists on the runner after the job completes, even if the workflow fails or is cancelled. The cleanup explicitly targets both the main repository and any submodule configurations that might have inherited the SSH settings.
Summary
The actions/checkout SSH security model follows a strict ephemeral workflow:
- Input isolation – Keys are accepted via the
ssh-keyinput and processed byGitAuthHelperwithout logging - Filesystem protection – Temporary files are created with
600permissions (Unix) or restricted Windows ACLs - Defense in depth – Known hosts verification combines user-defined entries with hardcoded GitHub keys to prevent MITM attacks
- Process isolation – Credentials are injected via
GIT_SSH_COMMANDenvironment variables rather than persistent SSH agent configuration - Guaranteed cleanup – The POST phase automatically deletes temporary files and scrubs Git configuration using state persisted via
state-helper.ts
Frequently Asked Questions
Where does actions/checkout store SSH keys during the workflow?
The action stores SSH keys in temporary files under the $RUNNER_TEMP directory, with specific paths saved to the action state via stateHelper.SshKeyPath. These files are created by the configureSsh() method in src/git-auth-helper.ts and are isolated to the specific workflow run.
What file permissions are set on the temporary SSH key files?
On Unix runners, the action explicitly sets mode 0o600 (read/write for owner only) when writing the key file. On Windows runners, it uses icacls.exe to remove inherited permissions and grant access exclusively to the current user, ensuring the key is never readable by other processes or users on the runner.
Does the SSH key remain available after the checkout step completes?
No, unless you set persist-credentials: true. By default, the POST cleanup phase immediately deletes the temporary key files and unsets the GIT_SSH_COMMAND environment variable. When persistence is enabled, the key remains available for subsequent Git operations within the same job, but is still deleted during the POST phase when the job ends.
How does the action prevent man-in-the-middle attacks during SSH authentication?
The action constructs a known-hosts file that includes hardcoded GitHub host keys alongside any user-provided entries. When ssh-strict is enabled (the default), it configures SSH with StrictHostKeyChecking=yes, causing the connection to fail immediately if the remote host's key does not match the expected values, preventing MITM attacks on the repository clone operation.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →