Verified bot commits with a GitHub App identity Jump to heading

Managing a signing key for every bot is real work: generation, storage, rotation, revocation. There is a shortcut on GitHub that many teams use without realising it. When a GitHub App creates a commit through the REST or GraphQL API — rather than pushing one created with git commit — GitHub signs it with its own key and marks it verified, attributed to the app. No key ever exists on the runner. The trade-off is that the signature now says “GitHub created this on the app’s behalf”, which is a different claim from “this key-holder created it”. This page shows how to do it and where that difference matters, within signed commits in CI pipelines.

When to use this approach Jump to heading

  • Your automation runs on GitHub and creates small commits — version bumps, generated files, lockfile refreshes.
  • You want those commits verified on the forge without storing a signing key anywhere.
  • Your signature gates either run on the forge or can trust the forge’s signing key, as described in verifying signatures on merge commits made by the forge.
  • If your verifiers must not depend on the forge’s key, sign on the runner instead with signing bot commits made by CI workflows.

Step 1 — Create a narrowly scoped app Jump to heading

Create an app owned by the organisation, grant it only contents: write (and pull_requests: write if it opens pull requests), and install it only on the repositories where it commits. Store its app ID and private key as secrets. The app’s private key authenticates the app; it is not a commit-signing key.

# Verification: the installation is limited to the expected repositories
gh api /orgs/$ORG/installations --jq '.installations[] | {app_slug, repository_selection}'

Step 2 — Get a short-lived installation token in the workflow Jump to heading

Exchange the app credentials for an installation token at the start of the job. The token lasts an hour and carries only the app’s permissions.

steps:
  - uses: actions/create-github-app-token@v1
    id: app
    with:
      app-id: ${{ vars.BOT_APP_ID }}
      private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
  - uses: actions/checkout@v4
    with: { token: "${{ steps.app.outputs.token }}" }
How the app's commit gets signedThe workflow exchanges the app's credentials for a one-hour installation token, sends the file changes to the API, and GitHub creates the commit on its servers, signing it with GitHub's key and attributing it to the app.workflowGitHub authGitHub APIapp ID + private keyinstallation token, 1hcreateCommitOnBranchbuild tree, sign commitcommit oid, verifiedthe runner never holds a commit-signing key — GitHub signs on the server side

Step 3 — Create the commit through the API, not with git push Jump to heading

A commit made locally and pushed with the app token is still unsigned: the token authenticates the push but does not sign anything. The commit must be created by the API. GraphQL’s createCommitOnBranch takes file additions and deletions and an expected head, which also protects against races.

#!/bin/sh
# ci/api-commit.sh <branch> <message> <file>...
set -eu
branch=$1 msg=$2; shift 2
head=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$branch" --jq .object.sha)
adds=$(for f in "$@"; do
  jq -n --arg p "$f" --arg c "$(base64 -w0 < "$f")" '{path:$p, contents:$c}'
done | jq -s .)

gh api graphql -f query='
  mutation($input: CreateCommitOnBranchInput!) {
    createCommitOnBranch(input: $input) { commit { oid url } }
  }' -F input="$(jq -n --arg repo "$GITHUB_REPOSITORY" --arg b "$branch" \
      --arg h "$head" --arg m "$msg" --argjson adds "$adds" '{
        branch: {repositoryNameWithOwner: $repo, branchName: $b},
        expectedHeadOid: $h,
        message: {headline: $m},
        fileChanges: {additions: $adds}
      }')"
# Verification: the new commit is verified and attributed to the app
gh api "repos/$GITHUB_REPOSITORY/commits/$branch" \
  --jq '{author: .commit.author.name, verified: .commit.verification.verified, reason: .commit.verification.reason}'
Pushing with the app token versus committing through the APIA commit created with git commit on the runner and pushed with the app token is unsigned, because the token only authenticates. A commit created through the API is built and signed by GitHub, so it arrives verified.git commit + pushcreateCommitOnBranchwho builds the committhe runnerGitHubsignaturenoneGitHub keyshows as verifiednoyeshandles binary/large changesyesawkwardthe token is authentication, not signing — only the API path gets a signature

Step 4 — Make your own verifiers agree with the forge Jump to heading

The forge shows these commits as verified. Your own gates — a CI range check or an audit script — see a signature from GitHub’s web-flow key and need to trust it. Import the pinned key, and narrow that trust to commits whose author is the app, so the forge key does not become a blanket pass.

# In the gate: accept the forge signature only for the app's commits
case "$(git log -1 --format='%ae' "$c")" in
  *"[bot]@users.noreply.github.com")
    [ "$(git log -1 --format=%G? "$c")" = G ] || fail "$c: app commit not forge-signed" ;;
  *) verify_human_signature "$c" ;;
esac

Step 5 — Know the limits of the claim Jump to heading

A forge signature on an app commit proves that the app, authenticated with its credentials, asked GitHub to create this exact commit. It does not prove anything about what code produced the change, and anyone with the app’s private key can produce such commits. Protect the app key like a signing key, and pair it with the same path restrictions you would put on any bot.

What an app-created commit actually provesThe signature proves GitHub created the commit. The app attribution proves the request came with the app's credentials. Nothing in it proves which workflow made the request or what it ran, which is why the app's private key and installation scope still need protection.from strongest to weakest claimGitHub created the commitforge signature verifiesThe app requested itinstallation token usedWhich workflow rannot proven — protect the app keypair app commits with path rules so a leaked app key cannot change arbitrary code

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Does this work for large or binary changes? Jump to heading

The API accepts base64 file contents, which is fine for version files and generated code but awkward for large binaries. For those, sign on the runner with a dedicated key instead, or store the binary elsewhere and commit a reference.

Do commits created through the API trigger workflows? Jump to heading

Yes, when made with an app token. That is useful for running checks on the bot’s change, but it means you need the same loop guard as for any bot that commits.

Can the app create a commit on a protected branch? Jump to heading

Only if branch protection allows the app to bypass pull requests or push directly. Prefer having the app open a pull request and letting normal review and the merge queue apply.