The GitHub token my public repo should never have held

A public repo's issue workflow holding a token with the repo scope, the three token options for a user-owned GitHub Project, and the scope check that now returns only project

I had just pushed the same set of agent files into ten repositories, one pull request each, and one of those repositories is public. Before merging, I asked a plain question: did we put anything sensitive into the public one?

The files were clean. No keys, no emails, no home-directory paths, no account numbers. The workflows referenced their secret by name, and GitHub masks secret values. By the usual checklist, the answer was no.

The answer was yes, just not in the diff. The secret those workflows referenced was a classic personal access token with the repo scope, and the same token sat in every repository, public ones included. repo means full control of every private repository on the account.

TL;DR

  1. A workflow in a public repo that runs on issues runs for anyone who can open an issue, with the repo's secrets in scope. Nothing leaked, but the token's reach was the whole account.
  2. The textbook fixes don't work for a user-owned GitHub Project: fine-grained tokens can't access Projects owned by a user account, and GitHub Apps can't either.
  3. Public repos now hold a second classic PAT with only the project scope. Their issues are public anyway, and closing issues uses GITHUB_TOKEN.
  4. Three layers keep it that way: the setup script refuses a broad token for a public repo, every workflow checks the token's scopes and fails closed, and a repo that goes public is re-secured automatically, at once and again daily.
  5. Overwriting a secret leaves no copy behind, but it doesn't revoke the old token. Check the run history before deciding whether to rotate.

One token, every repo

Every side project I run feeds one GitHub Project, a user-owned board I use as a Gantt chart. Two small workflows in each repository keep it current: one adds every new issue to the board, the other moves an issue to In Progress and Done as its spec's tasks get checked off.

The built-in GITHUB_TOKEN can't do either. It is scoped to its repository, and the board lives at the account level. So the workflows use a personal access token stored as a PROJECT_TOKEN secret. A classic token with project and repo scopes worked everywhere, private repos included, and one rotation command updated all thirteen copies. It was convenient. Convenience is how one credential ends up in thirteen places with the same reach.

What a public repository exposes

The issue workflow in a public repo starts with this trigger:

on:
  issues:
    types: [opened, reopened, transferred]

Anyone with a GitHub account can open an issue on a public repository. Each one starts a run with PROJECT_TOKEN in its environment.

As written, that wasn't exploitable, and it's worth saying why:

  • The workflow passes only the issue's number and node ID, both generated by GitHub, never its title or body. A crafted issue has nothing to inject.
  • The repository allows only its own actions, so no third-party code runs next to the secret.
  • The other workflow runs on pushes to the integration branch, which needs write access.
  • GitHub doesn't pass secrets to workflows triggered from forks.

Safe as written is one edit away from unsafe, though. A future pull_request_target trigger, or one echo "${{ github.event.issue.title }}" in a run: step, and a stranger can make the runner print or send the token. With repo scope, that is every private repository on the account. The risk wasn't a bug. It was the size of the blast radius.

The two obvious fixes don't apply

The first suggestion, from the AI agent I work with, was to replace the token with a GitHub App or a fine-grained token. Both are the modern advice, and both are wrong here.

  • A GitHub App gets a bot identity, permissions you choose, and tokens that expire in an hour. But a bot can't be added as a collaborator on a user-owned Project, and app installation tokens get Resource not accessible by integration on one. Apps work for Projects owned by an organization.
  • A fine-grained token can be limited to a few repositories and specific permissions. GitHub's own documentation lists the gap: fine-grained tokens can't access Projects owned by a user account.

The agent recommended the GitHub App before checking whether it supports user-owned Projects. When I asked what a GitHub App actually was, it went back to the docs and the community threads, found the limitation, and corrected itself. Then it recommended a fine-grained token, and found the same limitation in GitHub's documented gaps while writing the setup script. Both corrections came from reading the documentation for the exact case, not the general one.

Moving the board into a free organization would unlock both options. That is a bigger change than this problem needs.

A second, narrow token

Public repos don't need the repo scope at all. Their issues are public, so resolving an issue's node ID works without it, and closing an issue already uses GITHUB_TOKEN. What they need is the project scope, to add items and set the status field.

So public repos now hold a second classic token with exactly that one scope. If it leaks, someone can edit the board. They can't read or push to anything private.

A classic token reports its scopes in the X-OAuth-Scopes header of any API response, which makes "is this the narrow one?" a question a script can answer. The setup script checks every scope against a short allow-list before it stores anything in a public repo:

token_scopes() {
  GH_TOKEN="$1" gh api -i /user 2>/dev/null | tr -d '\r' \
    | sed -n 's/^[Xx]-[Oo][Aa]uth-[Ss]copes: *//p' | head -1
}

is_narrow_token() {
  local scope
  for scope in $(token_scopes "$1" | tr ',' ' '); do
    case "$scope" in project|read:project|read:org|read:user) ;; *) return 1 ;; esac
  done
  return 0
}

It reads each repository's visibility first. A public repo gets the narrow token, and the broad one is refused there by construction. I tested the gate both ways: the broad token is rejected, project alone is accepted, and project, public_repo is rejected.

Three layers so it stays narrow

A script only helps when someone runs it. Two situations bypass it: a token stored by hand, and a private repository that later becomes public with the broad token still inside. Both are covered.

The workflows check their own token. Both workflows now start with a step that runs before the token does anything else:

- name: Refuse a broad token in a public repo
  env:
    REPO_IS_PRIVATE: ${{ github.event.repository.private }}
  run: |
    if [ "$REPO_IS_PRIVATE" = "true" ]; then exit 0; fi
    SCOPES=$(GH_TOKEN="$PROJECT_TOKEN" gh api -i /user 2>/dev/null | tr -d '\r' \
      | sed -n 's/^[Xx]-[Oo][Aa]uth-[Ss]copes: *//p' | head -1)
    for SCOPE in $(printf '%s' "$SCOPES" | tr ',' ' '); do
      case "$SCOPE" in
        project|read:project|read:org|read:user) ;;
        *) echo "::error::This repo is public but PROJECT_TOKEN has the \"$SCOPE\" scope."; exit 1 ;;
      esac
    done

Private repositories skip it. A public repository holding anything broader stops with the fix command instead of carrying on. An unknown visibility is treated as public.

Going public re-secures the repo. GitHub has a public event that fires once, when a repository changes from private to public, and only an admin can cause it. Every repository gets a small standalone workflow on that event. It sends a repository_dispatch to the private repository that holds the canonical workflows, where a second workflow stores the narrow token in every public repository that has the automation.

That second workflow also runs every morning, so a missed event fixes itself within a day. It checks something the token can't: private repositories can share reusable workflows only with other private repositories. A repository that goes public keeps calling workflows it can no longer reach, and its automation stops. The daily run compares each public repo's workflow files with the full copies and fails, which sends an email, until they match.

How a public repo stays on the narrow token: going public dispatches a re-secure run, which stores the project-only token that the workflows' guard step checks

The daily run costs about a minute of Actions time.

Proving it, and the question that came after

The first manual run of the re-secure workflow verified both tokens, found the one public repository with the automation, and stored the narrow token in it. To see the guard run for real, I opened a throwaway issue there. The run logged Public repo: PROJECT_TOKEN is narrow (project)., added the issue to the board, and passed. Then I removed the item from the board and closed the issue, so the Gantt never showed it.

The next question was the right one: is the old token deleted from the public repo? Overwriting a GitHub secret replaces its value, and GitHub keeps no history, so the public repository no longer holds the broad token. But overwriting isn't revoking. The broad token is still valid and still used by the private repositories. The only way it could have leaked was through a workflow run, so the run history settles it: six runs in that repository before the fix, all started by me. No rotation needed. If the history had shown runs from forks or strangers, I would have minted a new broad token and revoked the old one the same day.

What this doesn't cover

  • Public back to private. Nothing detects that direction. That repository's issues become private and need the broad token again, so I run the setup script in it by hand.
  • Fine-grained tokens may catch up. GitHub lists the user-owned Projects gap as one it intends to close. Fine-grained tokens send no X-OAuth-Scopes header, so both checks accept one as narrow, and the script still verifies it can read the board before storing it.
  • The broad token still exists. It's narrower in practice, since it no longer sits where strangers can trigger runs, but its scope is unchanged. Rotating it in every repo is one command.

Rules that generalize

  • Audit a credential by its reach, not its visibility. A masked secret with account-wide scope is a bigger risk than a leaked one with none.
  • Match the token to the repository's audience. Public repositories run on events strangers can cause, so they get the narrowest token that does the job.
  • Read the limitations page for your exact case. Both modern alternatives were right in general and wrong for a user-owned Project.
  • Make the workflow check its own credential. A guard step that fails closed catches a mistake that no setup script ever ran for.
  • Treat a visibility change as an event, not a setting. If going public changes what a repository may hold, automate the consequences.
  • Overwriting isn't revoking. Decide on rotation from the evidence, the run history, not from whether the secret was replaced.

References