Five days ago GitHub put workflow dependency lockfiles into public preview, and last week we measured what that actually fixes: 7,802 of 20,194 action references point at a tag that can move under you. A lockfile pins the code your pipeline runs. It says nothing about the credential that code runs with.
So we went after the other half. Every GitHub Actions job is handed a GITHUB_TOKEN scoped by a permissions block. Omit the block and the job still gets a token: whatever your repository or organisation has set as the default, which is a setting most of the people writing workflows cannot read.
We pulled every workflow file from the 500 most-starred active public repositories on 19 October 2026. 411 run Actions, between them 5,271 workflow files and 12,665 jobs. Then we asked GitHub what the default actually was.
TL;DR
- 2,643 of 12,665 jobs (20.9%) declare no
permissionsat all, at either the workflow or the job level. 236 of 411 repositories have at least one. 63 have never scoped a single job. - You cannot find out what that means from outside. We asked all 63 what their default was. All 63 returned HTTP 403, with an identical message. The answer is visible only to an administrator.
- Repository age does not tell you either. GitHub’s 2023 change made new repositories read-only by default, but new repositories inside older organisations inherit the parent. 73 of our 411 are in exactly that position.
- There is one case where omission is documented to mean read/write:
pull_request_target. 334 workflows use it; 18 have at least one unscoped job. - The gap tracks CI maturity, not company size. Repositories with one or two workflows leave 62.0% of jobs unscoped. Repositories with 26 or more leave 14.7%.
- The fix is a two-line block. 3,695 of 6,503 declared blocks name exactly one scope, because any scope you do not name is set to
none.
Why an absent block is not a neutral one
The permissions key does two jobs, and the second one is the useful one. It grants what you name, and it revokes everything else. GitHub’s workflow syntax reference puts it plainly: “If you specify the access for any of these permissions, all of those that are not specified are set to none.”
That is a deny-by-default switch hiding inside what looks like a grant. Writing contents: read does not add a permission; it removes sixteen. 2,779 of the declared blocks in our corpus are exactly that one line and nothing else, the single most common configuration we found.
Leave the key out and none of that happens. The token is minted from the repository default, which an administrator sets to read or write and which is inherited from the organisation unless someone overrode it. Nothing in the workflow file records which, and nothing in a diff shows it changing.
Finding one: one job in five is running on an undeclared token
Of 5,271 workflow files, 2,942 (55.8%) declare a top-level permissions block, 640 (12.1%) use the zero-scope form permissions: {}, 63 use read-all, 14 use write-all, and 1,612 (30.6%) declare nothing.
Workflow level is half the picture, because a job can carry its own block. At job level: 3,694 declare their own, 6,328 inherit one from the workflow, and 2,643 (20.9%) have neither.
At repository level, 175 of 411 scope every job they run. 173 scope some. 63 have not scoped a single job in any workflow they have.
The split that keeps that honest: some of the worst offenders are not software but awesome-lists, roadmaps and interview-prep repositories with one link-checker apiece. Strip out every repository with no primary language and the figure barely moves. Across the 393 that are actual code, 2,626 of 12,637 jobs are unscoped, 20.8% against 20.9% for the whole corpus. One in five either way.
The exposed subset matters more. 1,420 of the 2,643 unscoped jobs sit in workflows an outsider can trigger, through a pull request, an issue, a comment or a fork event. The rest run on pushes and schedules, where whoever pulled the lever already had commit access.
Finding two: we asked 63 repositories what their default was, and all 63 refused
GitHub exposes the setting through the REST API at /repos/{owner}/{repo}/actions/permissions/workflow, which returns one of two values, read or write. We called it against all 63 repositories that never scope a job. These are public: we can read every line of their source, every workflow file, every commit. Every single one returned HTTP 403, with a byte-identical message:
“You must have repository read permissions or have the repository Actions policies fine-grained permission.”
We do have repository read permissions. They are public. The endpoint wants administrative access and the error does not say so.
Per our usual rule that an absence needs a control probe, we ran the same call against our own estate. On three repositories where our account is an administrator it returns 200 and answers: {"default_workflow_permissions": "read"}. On REPTILEHAUS/app-in-a-box, a private repository our account commits to with push access, it returns 404 Not Found.
Three different answers to one question, decided entirely by your role. An administrator sees the setting. A member of the public gets a 403 that misdescribes why. A developer with write access to the repository, who is the person most likely to be editing the workflows, gets told the endpoint does not exist. That is the structural problem: the people who decide whether to write a permissions block are routinely not the people who can see what happens if they do not.
Finding three: you cannot infer it from the repository’s age either
The obvious hypothesis is that this is a legacy problem: on 2 February 2023 GitHub changed the default for new accounts to read-only, calling the old read/write default “too permissive”, so newer repositories should be safe by omission. Read the rest of that changelog and it collapses. “This change will not impact any existing enterprises, organizations or repositories.” And, crucially: “Repositories owned by organization: New repositories will inherit permissions from parent organization.”
A repository created last week inside an organisation created in 2016 inherits the 2016 default. 73 of our 411 repositories are exactly that shape, created after the flip under an account created before it, and 40 of those 73 have at least one unscoped job. Creation date is not a proxy.
Nor is owner age, which we checked next and which gave us a genuine null. Repositories under pre-2023 owners leave 23.3% of jobs unscoped against 14.7% for post-2023 owners, but 64.6% of those newer owners have at least one gap against 55.7% of the older ones. The two measures point in opposite directions and we are reporting that rather than picking the one that reads better. No observable signal predicts the setting, which is precisely why those 63 HTTP 403s matter.
Finding four: the one trigger where omission is documented to mean write
There is a single exception where you do not need the repository setting to know the answer, and GitHub states it in the same reference page: “When a workflow is triggered by the pull_request_target event, the GITHUB_TOKEN is granted read/write repository permission, even when it is triggered from a public fork.”
334 workflows across 152 repositories use pull_request_target. 16 declare no permissions anywhere in the file; 18 have at least one unscoped job. For those, the token is read/write, by documentation, regardless of what any administrator has configured.
The trigger exists for a good reason: it runs the workflow definition from your default branch rather than the fork’s, which is why it is the safe way to label a pull request. It turns dangerous only when the job then checks out the contributor’s code and runs it. We looked for that intersection. 22 jobs under pull_request_target check out a pull-request-derived reference, but 14 of them use base.sha or base.ref, which is the trusted target branch and is fine. Eight check out a fork-controlled reference: head.sha, merge_commit_sha, or refs/pull/N. Two of those eight carry no permissions block anywhere in the file, and when we pulled the run history of one, all twenty most recent runs had fired from forked repositories.
We are not naming those two; publishing a live map of instances helps the wrong people. The point is the recipe, not the roster. pull_request_target, plus a checkout of the contributor’s commit, plus no permissions block, is the combination to grep your own repositories for this afternoon.
Finding five: the token reaches further than the YAML suggests
938 workflow files declare nothing anywhere, in any job. Only 138 so much as mention secrets.GITHUB_TOKEN or github.token, which reads as reassuring until you reach the documentation: “An action can access the GITHUB_TOKEN through the github.token context even if the workflow does not explicitly pass the GITHUB_TOKEN to the action.”
Grepping for the token name tells you what you use, and nothing about what the third-party actions you call can reach. 138 is the wrong denominator and GitHub says so.
There is a sharper version. We read the action definition for actions/checkout at v5, v6, v7 and main: persist-credentials still defaults to true on every one of them, meaning the token is written into .git/config and stays available to every later step in the job. Of 9,036 checkout steps in the corpus, 5,880 (65.1%) leave that default and 3,092 (34.2%) explicitly set it to false. 2,037 of the default-persist checkouts sit inside jobs with no permissions block, so an undeclared token is being written to disk and left there.
Finding six: this tracks CI maturity, not company size
We expected star count to predict discipline. It does not: sorted into quintiles by stars, the unscoped rate runs 17.1%, 11.2%, 22.6%, 31.9%, 30.6%. No clean gradient.
Sort by how much CI the team actually runs and it falls off a cliff, monotonically:
- 1 to 2 workflows: 102 repositories, 62.0% of jobs unscoped
- 3 to 5 workflows: 72 repositories, 40.5%
- 6 to 10 workflows: 83 repositories, 38.4%
- 11 to 25 workflows: 100 repositories, 19.6%
- 26 or more workflows: 54 repositories, 14.7%
Teams that live in their pipeline learn the habit. Teams with one deploy workflow and a linter never do, and they are four times worse. That is the finding with the most direct bearing on the businesses we work with, because the typical SME repository is not a 300-workflow monorepo. It is a build, a test and a deploy, written once by someone who has since moved on.
Finding seven: even the list of scopes has drifted
Our corpus uses 18 distinct permission keys across 6,503 declared blocks. GitHub’s current workflow syntax page documents 17, and they are not the same 17. repository-projects (20 uses, across Electron, Stirling-PDF and Cypress among others) and models (9 uses) appear nowhere on that page, and both still work: we checked the run history of workflows using each and found successful runs from this week. Meanwhile code-quality is documented and used zero times in the entire corpus.
The models entries are the interesting ones. Almost every use is an AI issue-triage workflow, at Nuxt, Axios, Appwrite, Home Assistant and Microsoft PowerToys, granting models: read so a language model can read incoming issues and flag spam. The newest scope in live deployment is the one letting agents into your issue tracker, and it is not in the table you would check.
What to do on Monday
- Put a top-level
permissions:block withcontents: readunder it in every workflow. Two lines, and it sets the other sixteen scopes tonone. It is what 2,779 blocks in our corpus already do. - Then grant upward, per job, not per workflow. The release job needs
contents: write. The linter does not. - Grep for
pull_request_targetfirst. If it appears alongside a checkout ofhead.sha,merge_commit_shaorrefs/pull/, fix that before anything else on this list. - Set
persist-credentials: falseon checkout unless a later step genuinely needs to push. - Check the repository setting once, with an administrator in the room, because your developers cannot see it. Settings, then Actions, then Workflow permissions.
If your pipeline predates whoever currently maintains it, this is the kind of audit we run as a fixed-scope piece of work: read every workflow, scope every job, and hand back a diff plus the list of things we found on the way. REPTILEHAUS builds and maintains CI/CD, DevOps and platform infrastructure for teams across Ireland and the EU, alongside our development, AI and Web3 work. Get in touch if you want someone to look at yours.
Method. 500 most-starred non-archived public repositories via the GitHub search API, 19 October 2026, minimum 50,167 stars. 411 returned a .github/workflows directory; 89 returned 404. All 5,271 workflow files downloaded and parsed with PyYAML, zero parse failures; note that on: parses as the boolean true under YAML 1.1 and must be normalised back. Default-permission probe run against all 63 all-unscoped repositories plus six of our own as controls. One figure in our first pass was wrong and the assert caught it: an early histogram put models at 94 uses, because the parser expanded read-all into an invented scope list instead of counting literal keys. The real figure is 9.
📷 Photo by mostafa mahmoudi on Unsplash
