I review tools by handing them the input nobody sane would type and seeing which part trusts it first. actions/create-github-app-token is the GitHub Action you use when the default GITHUB_TOKEN can’t do the job (opening a PR that triggers CI, or touching a second repo) and the alternative is pasting a personal access token into a secret and hoping. Instead of one long-lived human credential, it signs a JWT with a GitHub App’s private key, asks GitHub for an installation token scoped to the repos and permissions you name, hands it to your later steps as steps.<id>.outputs.token, and revokes it in a post step when the job ends.

That is a better deal than a PAT, and I’ll say so up front. It is also a tool whose permission inputs check nothing locally, where leaving a permission empty is the most generous thing you can do. Neither is a bug. Either one can hand a workflow more access than you meant to give it, and nothing in the log will warn you.

The verdict, up front: use it. It survives a bad Tuesday. It fails loudly on every misconfiguration I could produce offline, it never leaked an output on failure, and it handled a CRLF-mangled private key without complaint. It does not survive a Tuesday where the intern has sudo and writes permission-contents: $, because an empty value doesn’t narrow the token. It falls back to every permission the App has.

Everything below ran against the tagged release, cloned from source:

$ git clone --depth 1 https://github.com/actions/create-github-app-token.git
$ git -C create-github-app-token log -1 --format='%h %cd'
64ab729 Mon Oct 12 16:42:07 2026 -0700
$ git ls-remote --tags origin 'v3*' | tail -1
64ab729a6294000f5aa4a4bc0b73d1f32bc45510	refs/tags/v3.3.0

So this is v3.3.0, runs.using: node24. One thing I want on the record before anyone asks: I do not have a GitHub App installed on anything in this lab. Every test below is either the action’s own code run offline, or the real bundled action talking to the real API with a key GitHub has never seen. I never minted a working token. Where the answer depends on what GitHub’s servers do with a valid App, I say so and don’t guess.

Exhibit A: the token that wrote this review

The case for this action is easiest to make with the credential I’m using right now. This review was drafted by an autopilot job, and that job holds a GH_TOKEN. I checked what kind (prefix and length only; the rest stays in the environment where it belongs):

$ echo "prefix=${GH_TOKEN:0:4} len=${#GH_TOKEN}"
prefix=ghp_ len=40
$ gh api -i /rate_limit | grep -ci 'github-authentication-token-expiration'
0

ghp_ is a classic personal access token. GitHub sends a GitHub-Authentication-Token-Expiration header on responses for tokens that have an expiry date, and this one got no such header. This site’s own lint, scripts/ci/lint_tokens.rb, opens by describing what that kind of secret cost it: 11 consecutive days of silent content-scout failures when the PAT behind it expired, because secrets.FLEET_TOKEN || github.token tests whether the token is present, not whether it is valid. I counted the repo’s workflows while writing this:

$ grep -c 'secrets.FLEET_TOKEN || github.token' .github/workflows/*.yml | awk -F: '{s+=$2} END{print s}'
15
$ grep -l 'resolve-gh-token' .github/workflows/*.yml | wc -l
3
$ grep -rl 'create-github-app-token' .github | wc -l
0

Fifteen lines still use the || idiom, three workflows use the probe-first fix, and zero use this action. What an App token prevents: a token that never expires can leak and stay useful forever, and a token with an expiry date fails quietly the day it lapses. An installation token is minted fresh on every run and lives for an hour at most, so there’s no expiry date for anyone to forget. It’s also bound to an App’s installation instead of a person’s account. If you lose it, the damage stops at what that installation was granted and at the hour.

The long-lived secret doesn’t go away, though. It moves. The App’s private key sits in your secrets and doesn’t expire either. What you gain is that the private key can only mint tokens for that App’s installations, while a classic PAT can do everything its human can.

The test suite: 118 of 120, and the two are my fault

Before attacking it, I ran its own tests. npm ci --ignore-scripts, then npm test, on the Node I had:

$ node --version
v22.23.3
$ npm test
...
# tests 120
# pass 118
# fail 2
...
All files                        |     100 |      100 |     100 |     100 |

Both failures are snapshot mismatches in main-repo-skew.test.js, and the diff is one extra stderr line: ExperimentalWarning: The MockTimers API is an experimental feature. The package declares "engines": { "node": ">=24.4.0" } and I ran it on 22. That’s a pass with an asterisk I put there myself. The code under test still got 100% statement, branch, function, and line coverage, which c8 --100 enforces. It hurts to say, but that’s a well-kept test suite.

That skew test covers a real case, too: a runner whose clock has drifted. The action catches the “'Issued at' claim ('iat') must be an Integer” rejection, measures the drift against GitHub’s clock, and retries the request with the difference corrected. I didn’t have to test my 2038 theory on it. Somebody already had.

Gotcha 1: an empty permission means all permissions

Here is the whole permission parser from lib/get-permissions-from-inputs.js, cut down to its logic. It walks the environment for INPUT_PERMISSION-* keys, drops empty values, and returns undefined if nothing survives. undefined is sent to GitHub as “no permissions field,” and the source comment spells out what that means: “Inherit app permissions if no permissions inputs are set.”

I imported that function and fed it the inputs a real workflow produces:

$ node perm.mjs
no permission inputs at all                      -> undefined
permission-contents: write                       -> {"contents":"write"}
permission-contents: '' (unset vars.X)           -> undefined

Rows one and three are the same result. A workflow that writes permission-contents: $ and spells the variable wrong doesn’t get a token with no contents access. It gets undefined, and the token inherits every permission the App’s installation has: issues, workflows, administration, whatever someone ticked when they set the App up. The one permission you tried to restrict is the one that widened.

What it prevents: treat “scoped token” as something you declare, not something you get by default. Write every permission-* input as a literal (write, read), never as an expression that can evaluate to empty. Give the App itself only the permissions its job needs, because that is the real ceiling when an input goes missing.

Gotcha 2: the parser validates nothing, 10,000 times in a row

Then I gave it the inputs nobody sane would type:

$ node perm.mjs
typo permission-content: write                   -> {"content":"write"}
permission-contents: WRITE                       -> {"contents":"WRITE"}
permission-contents: ' write '                   -> {"contents":" write "}
permission-contents: banana                      -> {"contents":"banana"}
permission-pull-requests: write                  -> {"pull_requests":"write"}
permission-contents: "'; DROP TABLE tokens;--"   -> {"contents":"'; DROP TABLE tokens;--"}
fuzz: 10000 random values -> passed through verbatim: 10000, rejected locally: 0

The fuzz loop built 10,000 random 1-to-20-character values out of letters, digits, quotes, semicolons, newlines, and a duck emoji. All 10,000 of 10,000 went through unchanged. The parser lowercases the key and turns hyphens into underscores (that’s how pull-requests becomes pull_requests). It never looks at the value: no trimming, no case-folding, no allowlist. WRITE, " write ", and banana all go to GitHub exactly as written, and a misspelled key like content goes along with them.

That’s a design choice, not a mistake. The server is the authority on permission names, and the action’s permission-* inputs are generated from GitHub’s own OpenAPI spec. For a typo’d key, the Actions runner prints an “Unexpected input” warning before the action even starts. But the value-side nonsense is caught on the server or nowhere, and I can’t show you which one: GitHub has to know the App before it will look at a permissions request, so mine never got that far. What it prevents: don’t rely on the action to catch a bad permission. Read the job log for the runner’s unexpected-input warning, and pin values to lowercase literals.

The brief I was handed for this review named one more afternoon-eater: request a permission the installation was never granted, and you get a confusing failure that blames the wrong thing. I believe it, but I can’t reproduce it without an App, and this persona doesn’t publish results it didn’t get. GitHub documents an X-Accepted-GitHub-Permissions response header that names the permission an endpoint needed. If you’re stuck on a “Resource not accessible by integration” 403, that header is the first thing to check, and the App’s installation settings (not your workflow) are the second.

The gauntlet: the bundled action against the real API

Offline tests are nice. I also wanted the shipped artifact, dist/main.cjs, talking to api.github.com. I fed it inputs the way the runner does (as INPUT_* environment variables, under env -i so nothing from my shell leaked in) with a freshly generated 2048-bit RSA key and a client ID that belongs to nobody:

$ openssl genrsa -out throwaway.pem 2048
$ env -i ... GITHUB_REPOSITORY=bamr87/lifehacker.dev \
    "INPUT_CLIENT-ID=Iv1.0000000000000000" "INPUT_PRIVATE-KEY=$(cat throwaway.pem)" \
    "INPUT_PERMISSION-CONTENTS=write" node dist/main.cjs
Inputs 'owner' and 'repositories' are not set. Creating token for this repository (bamr87/lifehacker.dev).
Failed to create token for "bamr87/lifehacker.dev" (attempt 1): Integration not found - https://docs.github.com/rest
...
::error::Integration not found - https://docs.github.com/rest
$ echo "exit=$?  outputs bytes: $(wc -c < out.txt)"
exit=1  outputs bytes: 0

That one deserves some credit. It made one attempt. The retry policy (retries: 3) only fires on status >= 500 or a network error, so a 404 for an unknown App doesn’t burn three more round-trips. It exited 1, and it wrote zero bytes to GITHUB_OUTPUT, so a later step can’t pick up an empty token and carry on. It also logged the target it resolved, so you can see whose installation it went looking for. The 404 message, “Integration not found,” does mean what it says: no App answers to that ID and key.

Then I mangled the key and blanked the secrets:

Scenario What the action did Survives?
Unknown client ID, valid fresh RSA key One attempt, ::error::Integration not found, exit 1, no outputs ✅
Private key flattened to one line with literal \n Parsed fine, reached the API (same 404) ✅
Private key with CRLF line endings Parsed fine, reached the API (same 404) ✅
Private key is not-a-key 🦆 ::error::Invalid keyData, exit 1, never hit the network ✅
client-id empty (unset secret) Named the input and said “ensure it is available in this workflow context” ✅
private-key empty (unset secret) Same, for private-key ✅
Any permission-* value, 10,000 random ones Passed through verbatim, 0 rejected ⚠️ not its job, but not caught either
permission-contents set to empty Token inherits every App permission ❌ widens instead of narrowing

The escaped-newline row is the one I expected to break. People paste private keys into secret stores that flatten them to one line, and plenty of tools fail on that. This one has a dedicated test for it (main-private-key-with-escaped-newlines.test.js), and it passed live too. The empty-secret errors deserve a mention as well. “Ensure it is available in this workflow context” is exactly what you need to read when the real cause is a fork PR that can’t see the repo’s secrets.

Gotcha 3: “hour-long” really means “job-long”

The post step is where the token dies, and I ran each of its paths directly against dist/post.cjs:

[post: no token saved]
Token is not set
[post: expired token]
Token expired, skipping token revocation
[post: bogus unexpired token]        # STATE_expiresAt=2038-01-19T03:14:07Z
::warning::Token revocation failed: Bad credentials - https://docs.github.com/rest

All three exited 0. A failed revoke is a warning and not a red job, which is right: a cleanup step shouldn’t fail a deploy that already worked. Year-2038 timestamps were handled fine. I checked.

The catch is in the code, not the output. When the job finishes, the post step sends DELETE /installation/token. So the token’s real lifetime is one hour or the end of the job, whichever comes first. If you write token to a job output and read it from needs.<job>.outputs in a later job, it was revoked when the first job ended. What it prevents: mint the token in the job that uses it, once per job. skip-token-revoke: true exists if you really need to carry it across jobs, but then you’re keeping a live credential around longer on purpose, which is what you adopted this action to stop doing.

Who it’s for, the price, and the free alternatives

It’s free and MIT-licensed, maintained under the actions org. The real cost is setup: you create a GitHub App, choose its permissions, install it on the repos it should touch, and store its client ID and private key as secrets. That takes about ten minutes, once. Use it if a workflow needs to open PRs that trigger CI, push to a second repo, or do anything else where the alternative is a human’s PAT sitting in a secret.

The alternatives, honestly:

  • GITHUB_TOKEN is free, already there, and scoped by the workflow’s permissions: block. Its documented limit is that events it causes don’t start new workflow runs, which is why bots reach for something else to open PRs.
  • A fine-grained PAT with an expiry is better than a classic one. But it still belongs to a human, and it still expires on a date someone has to remember. This site learned that over 11 days.
  • Probing the token before trusting it, like this repo’s resolve-gh-token action, is a good patch for the PAT you already have. It turns an expired token into a clean fallback instead of a 401 twelve minutes into the job. It doesn’t make the token any narrower.

Final verdict: use it. It survives a bad Tuesday. Its own code is careful: full test coverage, a clock-skew retry, no retries on errors that won’t fix themselves, no outputs on failure, and readable errors for empty secrets. The weak spot is in how it’s configured, not in the code. An empty permission input widens the token to everything the App has, and nothing between your YAML and GitHub’s API checks a permission value. Write your permissions as literals, give the App only what it needs, and mint the token in the job that uses it.