Skip to content

Blog

GitHub Actions security hardening: a checklist before 2 December 2026

·

On 2 December 2026 GitHub Actions will start blocking the pull_request_target event by default in public repositories, unless an Actions event policy explicitly allows it. GitHub scheduled two brownouts ahead of that, on 9 November and 16 November, from 16:00 to 18:00 UTC. During a window a blocked attempt produces a failed run with an error annotation instead of running.

That date is the reason to read this now. The rest is the reason to keep it: a checklist of the changes that matter for a workflow that holds secrets, each with the YAML before and after. Every date and feature status below was read from GitHub’s own changelog and documentation on 10 November 2026, and where a feature is narrower than its headline I say so.

What changes on 2 December

The rule applies to public repositories only. Private and internal repositories are not affected, and it does not apply to GitHub Enterprise Server. It does not replace an event policy you have already configured, and GitHub’s documentation says it is enforced for repositories that were using the default policy before general availability. The date has already moved once: an earlier schedule said 2 November.

If a workflow needs the trigger, an owner or administrator creates an Actions event policy that explicitly allows pull_request_target, under Settings, then Actions, then Policies. A policy can be targeted at specific workflow files, so one labeller does not have to open the door for everything else. GitHub’s advice is to allow the event only where it is necessary.

GitHub points to policy Insights to see which runs the rule would block. The documentation marks evaluate mode as Enterprise Cloud only, so I would not assume every organisation gets a preview. The check that works everywhere is a search of your own workflow files:

grep -rnE 'pull_request_target|workflow_run' .github/workflows

Every line that prints is a decision: rewrite it onto a safer trigger, or allow it on purpose.

Three doors, two incidents

In March 2025 the attackers behind CVE-2025-30066 retroactively moved several version tags of tj-actions/changed-files to a malicious commit. Between 14 and 15 March, a workflow that ran one of those tags executed code that read secrets out of the runner’s process memory and printed them to the workflow log. The advisory estimates the action was used by 23,000 repositories or more. A tag is a name, and the name moved.

On 11 May 2026, between 19:20 and 19:26 UTC, 84 malicious versions of 42 @tanstack packages were published. The maintainers’ postmortem describes three weaknesses chained together: a pull_request_target workflow that ran code from a fork in the base repository’s context, cache poisoning across the fork and base boundary, and an OIDC token read out of the runner’s memory. It also says that no npm tokens were stolen and the publish workflow itself was not compromised. The attacker did not need a stored secret. They needed one job that ran their code and one cache that trusted it.

Read together they are three separate doors: a reference that can be repointed, a privileged trigger that runs untrusted code, and a cache shared between the two. The sections below close them one at a time.

1. Start from no permissions, and keep event data out of the shell

Once a workflow specifies any permissions, every permission it does not name is set to none, and permissions: {} disables them all. That makes the safest layout an empty block at the top and a grant on each job that needs one. It matters most on pull_request_target, where the documentation says the GITHUB_TOKEN is granted read and write repository permission even when the run was triggered from a public fork.

Set the repository default as well, so that a workflow you forgot to edit starts read-only. Under Actions, then General, then Workflow permissions, the restricted setting gives the token read access to contents and packages only. GitHub documents that as the default for a new personal repository; a new organisation repository inherits the organisation’s setting, so check yours rather than assume.

The second cheap fix is script injection. An expression such as ${{ github.event.issue.title }} is substituted into the script text before the shell sees it, so a title can carry commands. GitHub’s guidance is to set the expression as an intermediate environment variable and read the variable in the script:

name: triage on: issues: types: [opened] jobs: triage: runs-on: ubuntu-latest steps: - run: gh issue comment ${{ github.event.issue.number }} --body "Thanks for opening ${{ github.event.issue.title }}" env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }}
name: triage on: issues: types: [opened] permissions: {} jobs: triage: runs-on: ubuntu-latest permissions: issues: write steps: - run: gh issue comment "$NUMBER" --body "Thanks for opening $TITLE" env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} NUMBER: ${{ github.event.issue.number }} TITLE: ${{ github.event.issue.title }}

2. Pin what you run

The tj-actions tags moved; a full commit SHA cannot. GitHub’s reference tells you to pin actions to a commit SHA and to verify that the SHA belongs to the action’s own repository and not a fork. Keep the version in a trailing comment so a person can still read it. Since August 2025 an administrator can also make GitHub enforce this: the allowed-actions policy has a setting that fails any workflow using an action that is not pinned to a full SHA.

These are the current releases as I read them from each repository’s releases on 10 November 2026, with the SHA each tag points at. Fetch your own the day you do this; do not copy mine.

name: build on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - run: npm ci && npm test
name: build on: [push] permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: lts/* - run: npm ci && npm test

The persist-credentials: false line is not about pinning. By default checkout leaves the token in the repository’s local git configuration for later steps to use. If no later step needs to push, turning that off removes something a compromised step could have read. The static analyser zizmor flags its absence, and that is a tool’s opinion rather than a GitHub rule.

A SHA pins the action you named. It does not pin what that action itself calls. Workflow lockfiles, generally available since 28 October 2026 on Enterprise Cloud, Team and Free, do: a .github/workflows/actions.lock file records the exact commit for every action your workflows use, including transitive ones, with the source repository’s numeric ids, and the locked commit is what runs. Your workflow can keep readable tags. GitHub says a lockfile supersedes SHA pinning for repositories that adopt it, and Dependabot updates the matching entry in the same pull request.

gh extension install github/gh-actions-lock gh actions-lock # pin every action and write .github/workflows/actions.lock gh actions-lock --verify # in CI: check the lockfile without changing it

Know the limits. A lockfile does not pin calls to reusable workflows (jobs.<id>.uses), so pin those by full SHA. It does not control inputs, secrets or the runner image. It is generated, never edited by hand, and the extension that writes it was at version 0.4.1 on 10 November, so expect its commands to change before you script around them. An administrator can also turn on a “Require lockfile” policy to make adoption mandatory.

3. Do you need pull_request_target at all?

Ask the question GitHub’s own page asks first: can pull_request do this? It runs on the pull request’s merge branch, and for a fork it applies a read-only token, withholds secrets and honours the fork approval policies. If the job does not need a secret, switch to it and the December change never touches you.

When it does need one, the documentation’s rule is that a workflow on pull_request_target or workflow_run must not explicitly check out untrusted code, including from a fork, because they run with elevated privileges and share the default branch’s cache. This is the pattern to look for, and it has the shape of the first weakness in the May compromise:

name: pr-tests on: pull_request_target: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} - run: make test env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Since actions/checkout v7, that checkout is refused by default when the code comes from a fork. GitHub backported the refusal to the supported major versions from 20 July 2026, but only for workflows that reference a floating major tag. A workflow pinned to an older SHA, minor or patch keeps its old behaviour until you upgrade it, so pin a v7 or later. The opt-out is an input called allow-unsafe-pr-checkout, named to stand out in review; treat adding it as a security decision. The refusal does not cover a run step that fetches the fork’s code with git or gh, or events such as issue_comment.

The fix is to separate handling the code from touching anything privileged. The first workflow runs the untrusted code on pull_request with a read-only token and hands over one small file:

name: pr-tests on: pull_request: permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: make test - run: echo "$PR_NUMBER" > pr-number.txt env: PR_NUMBER: ${{ github.event.number }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-number path: pr-number.txt

The second runs from your default branch with the token it needs, and treats what it receives as data. It checks out nothing, and it checks that the file holds a number before using it:

name: pr-comment on: workflow_run: workflows: [pr-tests] types: [completed] permissions: {} jobs: comment: if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: actions: read pull-requests: write steps: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: pr-number run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - run: | number="$(cat pr-number.txt)" [[ "$number" =~ ^[0-9]+$ ]] || exit 1 gh pr comment "$number" --body "Tests passed." env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }}

zizmor will still flag the workflow_run trigger as dangerous. That is a prompt to read the workflow, not proof it is wrong; what makes it safe is that nothing in it executes the artifact.

4. Treat the cache as an input

The cache was the second door in May. Since 26 June 2026, runs triggered by an event that someone who is not a collaborator can fire, and that resolve to the default branch, receive a read-only cache token for the default branch’s scope. The examples GitHub gives are pull_request_target, issue_comment and workflow_run. A blocked save logs a warning and the job carries on; restores are not affected. pull_request keeps read and write, because it uses a scope other than the default branch’s.

If one of those workflows used to populate the cache, move the saving to a workflow on a trusted trigger such as push and let the others restore from it.

Since 10 September 2026 you can say it yourself with cache-mode, generally available on every plan on github.com. It takes read, write, write-only or none, at the top of a workflow or on a job, and a job’s value overrides the workflow’s. A called reusable workflow cannot be given more cache access than its caller granted. Setting a write mode on a low-trust trigger switches the protection off and brings the risk back, and Actions adds a warning annotation when you do. A workflow that handles untrusted input and has no use for a cache can drop it entirely:

name: label on: issue_comment: types: [created] cache-mode: none permissions: {} jobs: label: runs-on: ubuntu-latest permissions: issues: write steps: - run: gh issue edit "$NUMBER" --add-label needs-triage env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} NUMBER: ${{ github.event.issue.number }}

Older versions of actionlint reject the key (1.7.12 did, on the day I checked), so expect a linter error until yours catches up.

5. Stop storing cloud keys

A long-lived cloud key in a repository secret is exactly what the tj-actions payload could read, along with every other secret the runner held. OpenID Connect replaces it with a short-lived token minted for the run. The job needs id-token: write, which GitHub notes does not grant write access to anything else, so give it to the deploy job and nowhere else.

name: deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: aws s3 sync ./dist s3://example-site-bucket env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: eu-west-2
name: deploy on: push: branches: [main] permissions: {} jobs: deploy: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: role-to-assume: arn:aws:iam::123456789012:role/example-site-deploy aws-region: eu-west-2 - run: aws s3 sync ./dist s3://example-site-bucket

The workflow is half of it. The other half is the role’s trust policy, and GitHub says you must define at least one condition on it so that untrusted repositories cannot request tokens for your role. Match the audience and the subject:

"Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:ref:refs/heads/main" } }

Check the subject format your repository actually emits. Repositories created after 15 July 2026, and any repository renamed or transferred since, use an immutable form that includes the owner and repository ids, such as repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main. Older repositories keep the previous form unless you opt in. A trust policy written for one will not match the other, and the failure looks like a permissions error.

OIDC removes the stored secret. It does not make a compromised job safe: in May the token was read out of a runner that was running the attacker’s code. That is why the first four sections come first.

What a runner cannot fix

A runner can keep jobs apart from each other. On runners.io every job runs in its own virtual machine, which no other job uses, so nothing one job leaves on its machine reaches the next. The isolation post and the security documentation say what that does and does not cover.

It cannot make a workflow safe that hands its own secrets to code it should not trust. A fork’s code running under pull_request_target with your token is just as dangerous on any machine, ours or GitHub’s, so the checklist below is the same wherever a job runs. OIDC works unchanged too: the migration guide lists it among the things that stay as they are.

The one-page checklist

1. Search for pull_request_target and workflow_run. For each hit, move to pull_request, split the workflow, or allow it with a policy aimed at that one file.

2. Put permissions: {} at the top of every workflow and grant per job. Set the repository default to read-only.

3. Never put github.event.* text straight into a run: script. Pass it through env:.

4. Pin every action to a verified full SHA, or adopt a lockfile, and pin reusable workflows by SHA either way.

5. Use actions/checkout v7 or later, set persist-credentials: false unless a step pushes, and treat allow-unsafe-pr-checkout as a security decision.

6. Save caches from push or schedule. Say cache-mode explicitly on anything that touches untrusted input.

7. Replace stored cloud keys with OIDC, grant id-token: write to the deploy job only, and put conditions on the role’s trust policy.

8. Add .github/workflows/ to CODEOWNERS, so that a change to a workflow needs a designated reviewer.

9. Run a static analyser in CI. I ran zizmor and actionlint over every sample above: zizmor found the unpinned actions, the missing persist-credentials and the unsafe triggers in the before files, and only the workflow_run prompt in the after files; actionlint caught the injection. Both are third-party tools, and both lag the platform.

What we do on our side of the line, in one place, is on the security page. If you are moving workflows over, the migration page opens the change as a pull request that you review.

Run your CI and your infrastructure on one bill

Connect GitHub

Card required. You pay only for the minutes your jobs run.