# GitHub Actions submodule checkout failed on a private submodule

> GitHub Actions submodule checkout failed almost always means the token cannot read the submodule repository. See which credential each URL needs.

Source: https://latchkey.dev/learn/github-actions/checkout-submodule-token-denied  
Updated: 2026-09-20

GitHub Actions submodule checkout failed is a credential message rather than a git one: the outer repository clones cleanly and only the submodule fetch stops. The credential the action configured is scoped to the repository running the workflow, and a submodule usually lives somewhere else.

## What this error means

The checkout step is green for several seconds, prints the submodule update command, starts cloning the first submodule and then stops. Git asks for a username, finds it cannot prompt because the runner has no terminal, and gives up. The outer repository is fully checked out on disk, which is what makes this read like a corrupt submodule pointer rather than a permissions problem. Remove `submodules` from the step and everything is green again, which is the strongest hint that nothing about the repository itself is wrong.

```Quoted from actions/checkout#738, with the reporter's own redaction
/usr/bin/git -c protocol.version=2 submodule update --init --force --depth=1 --recursive
Cloning into '<MY_SUBMODULE_REPO>'...
Error: fatal: could not read Username for 'https://github.com/': terminal prompts disabled
```

## Common causes

### The automatic token cannot read the submodule repository

The commonest cause and the one the error never names. `GITHUB_TOKEN` is an installation token whose permissions, in the documentation's words, are limited to the repository that contains your workflow. Raising `permissions: contents: read` does nothing here, because the limit is which repository, not which scope.

### Credential persistence is switched off

With `persist-credentials: false` the action skips the whole of `configureSubmoduleAuth`, so no `insteadOf` rule and no `core.sshCommand` is written for any submodule. The outer clone still succeeds because the action fetches it directly, which is why this looks like a submodule-only fault.

### An SSH URL with no key to match it

A `.gitmodules` entry starting `git@github.com:` is rewritten to HTTPS when no `ssh-key` is supplied, so the fetch then depends on the token like any other. If you expected SSH to be used, the failure message mentioning an HTTPS URL is the tell that the rewrite happened.

### The submodule is nested and only one level was requested

Setting `submodules: true` initializes one level. A submodule inside a submodule is never fetched, so a later build step reports a missing directory rather than an authentication failure. Less common, and worth ruling out only after the credential is known to be right.

## How to fix it

### Give the step a token that can read the other repository

Create a fine-grained token or a GitHub App installation token with read access to the submodule repositories, store it as a secret and pass it as the `token` input. The action configures it for the outer repository and, through the `insteadOf` rule, for the submodules too.

```.github/workflows/ci.yml (illustrative)
- uses: actions/checkout@v5
        with:
          submodules: recursive
          token: ${{ secrets.SUBMODULE_TOKEN }}
```

### Or supply a deploy key and keep the SSH URLs

1. Add a deploy key to the submodule repository with read access.
2. Store the private half as a secret in the repository that runs the workflow.
3. Pass it as `ssh-key`, which makes the action set `core.sshCommand` inside each submodule.
4. Leave the `.gitmodules` URLs as SSH: with a key present they are no longer rewritten.

### Check whether you turned persistence off

Search the workflow for `persist-credentials`. If it is false, either remove it for the job that needs submodules, or keep it and authenticate the submodules yourself before the fetch. Turning it back on for one job is usually the smaller risk, because the action removes the credential in its post-job step either way.

```.github/workflows/ci.yml (illustrative)
- uses: actions/checkout@v5
        with:
          submodules: recursive
          persist-credentials: true
```

### Standardize the URLs rather than working around them

Mixed SSH and HTTPS entries in one `.gitmodules` file mean two credential paths to keep working. Rewrite them to one scheme and commit the change, so CI and laptops agree. HTTPS is the easier one to support in CI because it uses the same token the rest of the job already has.

```Terminal
git config --file .gitmodules submodule.libfoo.url https://github.com/org/libfoo.git
git submodule sync
git add .gitmodules && git commit -m "use https for submodules"
```

## How to prevent it

- Assume the automatic token stops at the repository boundary and plan submodules around that.
- Keep one URL scheme in `.gitmodules` so CI and local clones need the same credential.
- Pin the credential to a service account or an App, never to a person who can leave.
- Request `submodules: true` rather than `recursive` unless you really have nested ones.

## A checkout that produces it

This workflow is written for this page and has never been run. It is the ordinary shape: a repository with one submodule that lives in a second private repository in the same organization, and a default checkout asked to bring submodules along. Nothing in it is deprecated and nothing is misspelled.

It fails because of what the action was given rather than what it was asked to do. With no `token` input, the action uses the automatic token, and the documentation for that token is explicit: "The token's permissions are limited to the repository that contains your workflow." A second repository is outside that limit however the permissions block is written.

```.github/workflows/ci.yml (illustrative)
name: ci
on: push

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          submodules: recursive
      - run: make build
```

## What the action configures, and when it does not

The submodule work is one method in the action, `configureSubmoduleAuth()` in src/git-auth-helper.ts. It clears whatever a previous run left behind, and then everything in it that configures a credential sits inside `if (this.settings.persistCredentials)`. That is the branch worth knowing about: turn credential persistence off and the action still checks out your submodules, but it configures nothing for them, so every fetch that needs authentication fails. Teams who set `persist-credentials: false` for good security reasons meet this the moment a private submodule enters the repository.

When persistence is on, the method does one of two things. With an `ssh-key` input it runs a `git config` inside each submodule to point `core.sshCommand` at the key the action set up. Without one it adds an `insteadOf` rule per submodule so that SSH-style remotes are rewritten to HTTPS and the token applies to them. The README describes the same behavior from the outside: "When the `ssh-key` input is not provided, SSH URLs beginning with `git@github.com:` are converted to HTTPS."

```actions/checkout, src/git-auth-helper.ts
async configureSubmoduleAuth(): Promise<void> {
  // Remove possible previous HTTPS instead of SSH
  await this.removeSubmoduleGitConfig(this.insteadOfKey)

  if (this.settings.persistCredentials) {
```

## Which credential each URL shape needs

Two decisions decide the outcome, and people usually only make one of them consciously. The first is what the `.gitmodules` entry says. The second is which input the checkout step was given. The table pairs them.

The row that surprises people is the third one. An SSH URL with no `ssh-key` is rewritten to HTTPS and then authenticated with whatever token the step holds, so it can work for a public submodule and fail for a private one in another repository, with no mention of SSH anywhere in the error.

| `.gitmodules` URL | Checkout input | Outcome |
| --- | --- | --- |
| `https://github.com/org/pub` | none | works, the submodule is public |
| `https://github.com/org/priv` | none | fails, the automatic token is repository-scoped |
| `git@github.com:org/priv.git` | none | rewritten to HTTPS, then fails for the same reason |
| `git@github.com:org/priv.git` | `ssh-key` | works, `core.sshCommand` is set per submodule |
| any private URL | `persist-credentials: false` | fails, nothing is configured for submodules |

> The URL is committed, so it is the same for everyone. A submodule that works on a maintainer's laptop over SSH and fails in CI is not behaving differently; the laptop simply has a key the runner does not.

## The two corrected shapes

A token with access to the submodule repository is the smaller change and keeps everything on HTTPS. Use a fine-grained personal access token or a GitHub App installation token with contents read on the submodule repositories, and keep it on a service account rather than a person. The README makes the same recommendation for the `token` input: "We recommend using a service account with the least permissions necessary."

A deploy key is the alternative, and it is the better one when the submodule belongs to a different owner or when you would rather not mint a cross-repository token. Supply it as `ssh-key`, and the action configures each submodule to use it. Keep `submodules: recursive` only if you have nested submodules; `true` is one level and is faster.

```.github/workflows/ci.yml, corrected (illustrative)
- uses: actions/checkout@v5
        with:
          submodules: recursive
          token: ${{ secrets.SUBMODULE_TOKEN }}

      # or, with a deploy key instead of a token
      - uses: actions/checkout@v5
        with:
          submodules: recursive
          ssh-key: ${{ secrets.SUBMODULE_DEPLOY_KEY }}
```

## Why there is no recorded run on this page

Recording this one would mean standing up a second private repository, pointing a submodule at it and minting a credential whose scope is the whole subject of the page. We are not going to publish a run whose interesting property is the shape of a credential boundary, and a redacted log of it would tell you nothing you cannot see in the one quoted above, which a reporter already published with their own redaction. The mechanism is also not observational: it is one branch in the action and one sentence about the automatic token's scope, both quoted here from the files that contain them. A green run after the fix would prove that our token worked, which is not a fact about anyone else's.

## FAQ

### Why does the main repository check out but the submodule fail?

Because they are fetched with different authority. The action clones the outer repository with the credential it was given, then asks git to update the submodules, which are separate repositories. The automatic token is limited to the repository containing the workflow, so a submodule elsewhere is refused.

### Does persist-credentials false break submodule checkout?

For private submodules, yes. The action's `configureSubmoduleAuth` method does all of its credential configuration inside a check on that setting, so with it false no credential helper and no SSH command is configured for any submodule. The outer checkout still succeeds, which makes this easy to misread.

### Do I need to change .gitmodules to use an SSH deploy key?

No. Supply the key as the `ssh-key` input and leave the SSH URLs in place. The rewrite to HTTPS only happens when no key is provided, so with a key present the entries are used as written and each submodule is configured to use that key.

### Can I give GITHUB_TOKEN access to another repository?

Not through the `permissions` block. That block adjusts scopes within the repository the workflow belongs to. Reading a second repository needs a different credential: a personal access token, a GitHub App installation token, or a deploy key on the repository you need to read.

## References

- [actions/checkout: README, submodules and ssh-key inputs](https://github.com/actions/checkout)
- [actions/checkout#738: terminal prompts disabled when submodules: true](https://github.com/actions/checkout/issues/738)
- [GitHub Actions: about the GITHUB_TOKEN and its scope](https://docs.github.com/en/actions/concepts/security/github_token)
- [Git: gitmodules, the submodule URL entry](https://git-scm.com/docs/gitmodules)
- [actions/checkout: git-auth-helper.ts, configureSubmoduleAuth](https://github.com/actions/checkout/blob/main/src/git-auth-helper.ts)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
