How actions/checkout Handles SSH Authentication for Submodules in GitHub Actions

actions/checkout writes the SSH private key to a temporary file, constructs a custom SSH command, and injects this configuration into both the parent repository and all submodules using Git's core.sshCommand and includeIf directives, ensuring seamless SSH authentication without manual Git configuration.

Fetching Git submodules over SSH in GitHub Actions requires secure credential handling that persists across nested repositories. The actions/checkout action automates this process by dynamically configuring Git authentication at runtime according to the source code in actions/checkout. When you provide an SSH key, the action intercepts Git commands to use custom SSH configuration, propagating these settings to every submodule automatically.

Parsing SSH Inputs and Initial Setup

The authentication process begins by reading SSH-specific inputs from your workflow configuration.

In src/input-helper.ts (lines 160-164), the action parses the optional SSH parameters you provide:

result.sshKey         = core.getInput('ssh-key')
result.sshKnownHosts  = core.getInput('ssh-known-hosts')
result.sshStrict      = (core.getInput('ssh-strict') || 'true').toUpperCase() === 'TRUE'
result.sshUser        = core.getInput('ssh-user')

If ssh-key is present, the action switches to SSH-based authentication for both the main repository and all submodules.

Temporary Key Storage

The action immediately writes the private key to a temporary file on the runner. In src/git-auth-helper.ts (lines 251-259), the code generates a unique path under $RUNNER_TEMP and sets strict file permissions (typically 0o600) to prevent unauthorized access:

const sshKeyPath = path.join(runnerTemp, uniqueId)
await fs.promises.writeFile(sshKeyPath, sshKey, { mode: 0o600 })

Simultaneously, the action constructs a known_hosts file (lines 298-304) combining your provided ssh-known-hosts input with GitHub's default host keys to prevent man-in-the-middle attacks.

Constructing the SSH Command

Once the key material is secured, actions/checkout builds a custom SSH command that overrides Git's default SSH behavior.

Building GIT_SSH_COMMAND

In src/git-auth-helper.ts (lines 215-224), the action assembles the command string:

this.sshCommand = `"${sshPath}" -i "${this.sshKeyPath}"`
if (this.settings.sshStrict) {
    this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
}
if (this.settings.sshUser) {
    this.sshCommand += ` -l "${this.sshUser}"`
}

Persisting Configuration to Git Config

The action stores this command in two locations to ensure all Git subprocesses use it. First, it sets the GIT_SSH_COMMAND environment variable (lines 312-313). Second, it writes to the repository's Git config as core.sshCommand (lines 317-322):

core.info(`Temporarily overriding GIT_SSH_COMMAND=${this.sshCommand}`)
this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand)
await this.git.config('core.sshCommand', this.sshCommand)

This dual configuration ensures that both the initial clone and any subsequent Git operations use the specified private key.

Propagating Authentication to Submodules

Submodules present a unique challenge because they maintain separate Git configurations. actions/checkout solves this using Git's includeIf directive.

The includeIf Configuration Pattern

In src/git-auth-helper.ts (lines 171-197), the action prepares a shared credentials configuration. When using SSH, this file contains the core.sshCommand setting. For each submodule, the action adds an entry to .git/modules/<name>/config that conditionally includes the shared file:

[includeIf "gitdir:/path/to/submodule/.git"]
    path = /path/to/git-credentials-*.config

This pattern ensures that when Git operates inside a submodule directory, it automatically inherits the parent's SSH configuration without duplicating sensitive data.

Submodule Configuration Injection

During the checkout process in src/git-source-provider.ts (lines 275-285), the action iterates through all submodules using git submodule foreach to inject the SSH command directly:

await git.submoduleForeach(
    `config --local core.sshCommand "${this.sshCommand}"`,
    true
)

This guarantees that git submodule update commands authenticate using the provided SSH key, regardless of whether the submodule URL uses git@github.com: or ssh:// formats.

Complete Workflow Examples

Basic SSH Authentication for Submodules

The following configuration recursively fetches submodules using an SSH key stored as a repository secret:

steps:
  - uses: actions/checkout@v4
    with:
      submodules: recursive
      ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
      ssh-known-hosts: |
        github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQ...
      ssh-strict: true

Under the hood, this creates:

  • $RUNNER_TEMP/<unique>.pem containing your private key
  • $RUNNER_TEMP/<unique>_known_hosts with verified host keys
  • GIT_SSH_COMMAND="ssh -i $RUNNER_TEMP/<unique>.pem -o StrictHostKeyChecking=yes -o CheckHostIP=no"

Mixed HTTPS and SSH Authentication

You can combine token-based HTTPS authentication with SSH for specific submodules:

steps:
  - uses: actions/checkout@v4
    with:
      submodules: true
      token: ${{ secrets.GITHUB_TOKEN }}
      ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
      persist-credentials: true

According to the source code in src/git-auth-helper.ts (lines 185-197), this creates two credential mechanisms:

  1. A git-credentials-*.config file for HTTPS URLs (lines 185-197)
  2. The core.sshCommand configuration for SSH URLs (lines 215-224)

The includeIf entries route each submodule to the appropriate authentication method based on its remote URL.

Disabling Credential Persistence

For security-hardened environments, disable token persistence when using SSH-only authentication:

steps:
  - uses: actions/checkout@v4
    with:
      submodules: true
      persist-credentials: false
      ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}

This prevents the action from writing the git-credentials-*.config file, leaving only the SSH command in the Git configuration.

Security and Cleanup Procedures

After the job completes, actions/checkout removes all authentication artifacts to prevent credential leakage between workflows. In src/git-auth-helper.ts, the removeAuth() method performs two critical operations:

Removing Submodule Configurations (lines 486-495): The action iterates through all submodules and strips the includeIf entries added during setup, ensuring subsequent jobs cannot access the previous SSH key.

Clearing Core SSH Command (lines 532-538): The action unsets core.sshCommand from the main repository config and deletes the temporary key files from $RUNNER_TEMP.

This cleanup executes automatically as a post-job step, even if your workflow fails or is cancelled.

Summary

  • Input Processing: src/input-helper.ts parses ssh-key, ssh-known-hosts, ssh-strict, and ssh-user to determine authentication strategy.
  • Command Construction: src/git-auth-helper.ts builds a temporary SSH command with strict host checking and writes it to both environment variables and Git config.
  • Submodule Propagation: The action uses Git's includeIf directive and git submodule foreach to inject core.sshCommand into every submodule's local configuration.
  • Dual Authentication: When both token and ssh-key are present, the action maintains separate credential files and routes each submodule to the appropriate mechanism.
  • Automatic Cleanup: Post-job execution removes all temporary keys, known-hosts files, and configuration entries from src/git-auth-helper.ts lines 486-538.

Frequently Asked Questions

How does actions/checkout pass SSH keys to nested submodules?

The action writes the SSH private key to a temporary file and constructs a custom SSH command. It then uses git submodule foreach to execute git config --local core.sshCommand in each submodule directory, as implemented in src/git-source-provider.ts (lines 275-285). This ensures all nested repositories use the same SSH key without requiring separate secrets for each submodule.

Can I use both HTTPS tokens and SSH keys simultaneously?

Yes. When you provide both token and ssh-key inputs, actions/checkout creates a credential helper file for HTTPS URLs and an SSH command configuration for SSH URLs. The includeIf entries in each submodule's Git config route authentication requests to the appropriate mechanism based on the remote URL's protocol, as handled in src/git-auth-helper.ts (lines 171-197).

Where are the temporary SSH keys stored during the workflow?

The action stores temporary files in the $RUNNER_TEMP directory with unique identifiers. Specifically, src/git-auth-helper.ts (lines 251-259) writes the key to path.join(runnerTemp, uniqueId) with 0o600 permissions, and src/git-auth-helper.ts (lines 298-304) creates a companion known-hosts file. These paths are injected into the SSH command but never logged or exposed in workflow outputs.

How do I disable strict host key checking for submodules?

Set ssh-strict: false in your workflow inputs. In src/git-auth-helper.ts (lines 215-224), the code checks this setting and omits the -o StrictHostKeyChecking=yes flag from the SSH command when disabled. This allows connections to hosts not present in your ssh-known-hosts input or the default GitHub host keys, though this reduces security against man-in-the-middle attacks.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →