# actions/create-github-app-token, from App setup to the errors

> Use actions/create-github-app-token for a one-hour App token that reaches other repos and triggers workflows. Get the setup, scope rules and errors.

Source: https://latchkey.dev/learn/actions/create-github-app-token  
Updated: 2026-11-12

`actions/create-github-app-token` exchanges a GitHub App's client ID and private key for an installation access token inside the job. Unlike `GITHUB_TOKEN` it can reach other repositories and its pushes start workflows; unlike a personal access token it belongs to no person, expires after one hour and is revoked when the job ends.

Three problems send people to actions/create-github-app-token. A workflow pushes a commit or opens a pull request and none of the checks run. A job needs to read or write a second repository and `GITHUB_TOKEN` cannot see it. Or a personal access token that belongs to someone who has left is holding a pipeline together.

An App installation token answers all three, at the cost of a one-time setup: register an App, install it, and store its client ID and private key. After that, the step's inputs decide which repositories the token reaches and which permissions it carries, and the token stops working an hour after it was minted.

## GITHUB_TOKEN, a personal access token or an App token

`GITHUB_TOKEN` is the right default and should stay so for anything inside the workflow's own repository that does not need to start another workflow. The other two exist for the cases it refuses.

A personal access token covers both, but it acts as a person: every commit and pull request carries their name, it reaches what they can reach, and it stops working when they leave. An App token acts as `<app-slug>[bot]`, reaches only the repositories the App is installed on, with only the permissions the installation was granted, and lives for an hour.

|  | GITHUB_TOKEN | Personal access token | App installation token |
| --- | --- | --- | --- |
| Reaches | The workflow's repository only | What the user can reach (fine-grained: the selected repositories) | Repositories where the App is installed |
| Its push starts `on: push` workflows | No | Yes | Yes |
| A pull request it opens runs `pull_request` checks | Only after someone approves the runs | Yes | Yes |
| Lifetime | Ends with the job | Until the expiry you set | One hour; this action also revokes it at job end |
| Acts as | `github-actions[bot]` | The user | `<app-slug>[bot]` |

> Trigger rules from docs.github.com, Triggering a workflow from a workflow; lifetimes from the GITHUB_TOKEN concepts page and the REST reference for installation access tokens. All read 2026-11-12.

## Set up the App once

GitHub's own guide has four steps. Do them in order; the last one is the easy one to miss. Register a GitHub App and give it only the repository permissions your workflows need, for example Contents and Pull requests at read and write. Store its client ID as an Actions variable; the client ID is a different value from the numeric App ID. Generate a private key and store the whole file as a secret, including the `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----` lines. Then install the App on the account and select the repositories it may touch.

Registering an App does not install it, even an App private to your organization. Until it is installed, the installation lookup the action makes returns `Not Found`.

```.github/workflows/app-token.yml
on:
  workflow_dispatch:
permissions:
  contents: read
jobs:
  demo:
    runs-on: ubuntu-latest
    steps:
      - id: app
        uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
        with:
          client-id: ${{ vars.APP_CLIENT_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
      - run: gh api "repos/$GITHUB_REPOSITORY" --jq .full_name
        env:
          GH_TOKEN: ${{ steps.app.outputs.token }}
```

## Inputs, and how they decide what the token reaches

`private-key` and one of `client-id` or `app-id` are the only values you must pass. The rest decide the token's reach. With neither `owner` nor `repositories`, the token covers the current repository. With `owner` alone, it covers every repository in that owner's installation. With `repositories`, it covers exactly those, and since v3.2.0 an entry may be written `owner/repo` as long as the owner matches. `enterprise` is separate: it targets an enterprise installation, can call enterprise APIs only, and cannot be combined with the other two.

By default the token inherits every permission the installation has. The `permission-*` inputs narrow it: prefix the permission name, so `pull-requests` becomes `permission-pull-requests: write`. Asking for a permission the installation was never granted fails the step instead of quietly giving you less.

| Input | Default | What it does |
| --- | --- | --- |
| `client-id` | none | The App's client ID; added in v3.1.0 |
| `app-id` | none | The numeric App ID; deprecated since v3.1.0 in favor of `client-id` |
| `private-key` | required | The private key; escaped `\n` sequences are turned into newlines |
| `owner` | the current repository owner | Installation owner to create the token for |
| `repositories` | the current repository, if `owner` is unset | Comma or newline separated; `owner/repo` entries accepted since v3.2.0 |
| `enterprise` | none | Enterprise slug; cannot be used with `owner` or `repositories`; added in v3.2.0 |
| `permission-<name>` | every installation permission | `read` or `write` (a few also accept `admin`), one input per permission |
| `skip-token-revoke` | `false` | Leave the token valid after the job instead of revoking it in the post step |
| `github-api-url` | `${{ github.api_url }}` | REST API URL, for GitHub Enterprise Server |

> Outputs: `token`, `installation-id` and `app-slug`. The lookups behind the scope rules are `GET /repos/{owner}/{repo}/installation` (for a list, the first repository), `GET /users/{owner}/installation` and `GET /enterprises/{enterprise}/installation`. Inputs, defaults and lookups from `action.yml`, the README and `lib/main.js` at v3.3.0, read 2026-11-12.

## A token for another repository, with two permissions

The common shape is a job in one repository that opens a pull request in another. Scope the token to the target repository, ask for the two permissions the job uses, check the target out with that token, and pass the same token to [peter-evans/create-pull-request](/learn/actions/peter-evans-create-pull-request). Because the push and the pull request come from the App, the target repository's `push` and `pull_request` workflows run on them.

If the job commits with plain `git` instead, set the committer to the App's bot so the commit is attributed to it: the name is `<app-slug>[bot]` and the email is `<user-id>+<app-slug>[bot]@users.noreply.github.com`, where the user ID comes from `gh api /users/<app-slug>[bot] --jq .id`. The `app-slug` output gives you the first half.

```.github/workflows/bump.yml
jobs:
  bump:
    runs-on: ubuntu-latest
    steps:
      - id: app
        uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
        with:
          client-id: ${{ vars.APP_CLIENT_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          repositories: deploy-config
          permission-contents: write
          permission-pull-requests: write
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          repository: ${{ github.repository_owner }}/deploy-config
          token: ${{ steps.app.outputs.token }}
          persist-credentials: false
      - run: ./scripts/bump-image-tag.sh "$GITHUB_SHA"
      - uses: peter-evans/create-pull-request@3ffccacd46053766916f087ce41d627c0b8cf2d1 # v8.1.3
        with:
          token: ${{ steps.app.outputs.token }}
          branch: bot/bump-image
          title: Bump the image tag
          sign-commits: true
```

## One hour, then 401

Installation tokens expire one hour after they are created. GitHub's REST reference says a call with an expired token gets `401 Unauthorized` and needs a new token; there is no way to extend one. A job that mints the token in its first step and pushes after a 70-minute build fails at the push. Mint the token immediately before the step that uses it, or mint a second one later in the job.

The action also revokes the token in its post step, so it cannot be handed to another job through outputs: each job mints its own. `skip-token-revoke: true` turns revocation off, and there is rarely a reason to. The token is masked in the log either way.

## Empty keys, missing installs and expired tokens

The first, fifth and sixth messages are verbatim from the action's source at v3.3.0, the second to fourth from issues #76, #153 and #235 on its repository, and the 401 from GitHub's REST reference. When the token is minted but a later API call returns 403 or 404, the cause is the installation's permissions or repository list, and the diagnosis is the same as for [GITHUB_TOKEN cannot access another repository](/learn/github-actions/github-actions-github-token-cannot-access-another-repo).

| Message | What it means | Fix |
| --- | --- | --- |
| `The 'private-key' input must be set to a non-empty string. If using a secret or variable, ensure it is available in this workflow context.` | The secret resolved to empty. Secrets are not passed to fork pull request runs or to runs triggered by Dependabot | Run from a trusted event, or also store the key as a Dependabot secret |
| `Not Found` on the installation lookup | The App is not installed on that owner or repository | Install it, or add the repository to the installation |
| `A JSON web token could not be decoded` | The private key is malformed or invalid | Store the whole key file again |
| `Invalid keyData` | The key was stored without its BEGIN and END lines | Store the full file, header lines included |
| `Cannot use 'enterprise' input with 'owner' or 'repositories' inputs` | Two kinds of target in one step | One target per step |
| `A proxy environment variable is set, but Node.js native proxy support is not enabled. Set NODE_USE_ENV_PROXY=1 for this action step.` | v3 dropped its own proxy handling | Set `NODE_USE_ENV_PROXY: "1"` in the step's `env` |
| `401 Unauthorized` later in the job | The token is more than an hour old | Mint it closer to where it is used |

## Moving from v1 or v2 to v3

v3 still accepts `app-id`, with a deprecation warning, so moving from v1 or v2 is a version bump plus, on proxied self-hosted runners, one environment variable.

| Release | Change |
| --- | --- |
| v2.0.0 (2025-04-03) | Removed the deprecated `app_id`, `private_key` and `skip_token_revoke` inputs |
| v3.0.0 (2026-03-14) | Node 24, needing runner v2.327.1 or later; proxies need `NODE_USE_ENV_PROXY=1` |
| v3.1.0 (2026-04-11) | `client-id` added, `app-id` deprecated |
| v3.2.0 (2026-05-12) | Enterprise installations; `owner/repo` entries in `repositories`; `private-key` validated |
| v3.3.0 (2026-10-12) | Permission inputs regenerated from GitHub's current list |

> From the release notes on the action's repository, read 2026-11-12.

## FAQ

### How long does a token from create-github-app-token last?

One hour from creation. GitHub's REST reference says an expired installation token gets a 401 and has to be replaced by a new one; it cannot be extended. The action also revokes it when the job ends, so in practice it lives for whichever is shorter.

### Should create-github-app-token work with Dependabot pull requests?

Yes, as long as the key reaches the job. Workflows triggered by Dependabot do not receive Actions secrets, so the private key has to be stored as a Dependabot secret as well. In issue #235 on the action's repository, the remaining failure turned out to be stray whitespace in one of the inputs.

### Why do I get a 404 when getting a token for an organization App installation?

Because the App is registered but not installed on that organization, which was the answer in issue #76. Registering creates the App; installing grants it the account's repositories. The action looks up the installation for the owner, and a missing installation comes back as `Not Found`.

### Why do workflows not run on commits pushed with GITHUB_TOKEN?

GitHub suppresses them to prevent recursive runs. The exceptions are `workflow_dispatch`, `repository_dispatch` and pull request runs, which wait for approval. A push made with an App token from this action starts workflows normally.

## References

- [actions/create-github-app-token README (read 2026-11-12)](https://github.com/actions/create-github-app-token)
- [actions/create-github-app-token action.yml at v3.3.0](https://github.com/actions/create-github-app-token/blob/v3.3.0/action.yml)
- [Making authenticated API requests with a GitHub App in a GitHub Actions workflow](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow)
- [REST API: create an installation access token for an app](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app)
- [Triggering a workflow from a workflow, docs.github.com](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow)

---

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
