DEV Community

Cover image for actions/create-github-app-token, from App setup to the errors
Kay
Kay

Posted on Originally published at latchkey.dev

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.

Four input combinations, the installation lookup each makes, and what the resulting token can reach
The target inputs pick the installation lookup and the token scope; the permission inputs then narrow what it may do there.

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.

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 }}
Enter fullscreen mode Exit fullscreen mode

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. 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: true
Enter fullscreen mode Exit fullscreen mode

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.

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

Top comments (0)