actions/create-github-app-token, from App setup to the errors
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] |
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.
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 |
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. 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.
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: trueOne 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.
| 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 |
Key takeaways
- An App token reaches other repositories and its pushes and pull requests start workflows;
GITHUB_TOKENdoes neither. - Register, store the client ID and the whole key, then install: a registered but uninstalled App returns
Not Found. - No target inputs means the current repository;
owneralone means the whole installation;permission-*narrows it. - The token expires after one hour and is revoked in the post step, so mint it per job, close to where it is used.
Frequently asked questions
How long does a token from create-github-app-token last?
Should create-github-app-token work with Dependabot pull requests?
Why do I get a 404 when getting a token for an organization App installation?
Not Found.Why do workflows not run on commits pushed with GITHUB_TOKEN?
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.Related guides
References
- actions/create-github-app-token README (read 2026-11-12)
- actions/create-github-app-token action.yml at v3.3.0
- Making authenticated API requests with a GitHub App in a GitHub Actions workflow
- REST API: create an installation access token for an app
- Triggering a workflow from a workflow, docs.github.com
- GitHub Actions documentation