
From Provision to Shutdown: The Lifecycle of a Tenki Runner

Default GITHUB_TOKEN permissions are dangerously broad. Here's how to scope every job to minimum access and eliminate static cloud credentials with OIDC.
Every GitHub Actions workflow gets a token. It's called GITHUB_TOKEN, and it can read your code, write to your repo, push packages, close issues, and create deployments. By default, on many repositories, it can do all of that at once. If a compromised third-party action runs inside your workflow, it inherits every permission that token carries.
That's the problem this article solves. We'll walk through exactly how the token works, why the defaults are dangerous, how to lock permissions down to the minimum each job actually needs, and how OIDC lets you drop static cloud credentials entirely.
When you enable GitHub Actions on a repository, GitHub installs a GitHub App behind the scenes. Before each job starts, GitHub uses that app to mint a short-lived installation access token scoped to the repository. That token is GITHUB_TOKEN.
A few things to understand about its lifecycle:
github.token context even if you never pass it explicitly. You don't have to write ${{ secrets.GITHUB_TOKEN }} for an action to use it.workflow_dispatch and repository_dispatch. This prevents infinite loops.The repository scope sounds reassuring, but the damage a token can do within a single repo is substantial. Write access to contents means an attacker can push code. Write access to packages means they can publish malicious artifacts under your org's name.
GitHub offers two default permission modes for the token, configured at the organization or repository level:
Repositories created under enterprise or organization accounts with updated policies now default to restricted mode. But if your org hasn't changed this setting, or if you're on a personal account with older repos, you're likely running permissive.
Forked repositories add another layer. When a pull_request event fires from a fork, the token is automatically downgraded to read-only regardless of your settings, and secrets aren't exposed. This is a safety net. But pull_request_target bypasses it, which we'll cover in detail.
The permissions key is how you override defaults. You can place it at the top level of the workflow (applies to all jobs) or inside a specific job (overrides the workflow-level setting for that job only).
Here's the critical behavior: when you specify any individual permission, all unspecified permissions are set to none. This is called scope zeroing, and it's the entire mechanism that makes least-privilege work. If you write contents: read and nothing else, then issues, packages, deployments, and every other scope drop to none.
Two shorthand values exist: write-all grants write access to every scope. read-all grants read access to every scope. Using write-all in a workflow file is essentially opting out of the security model. Don't do it.
Each permission scope maps to a set of GitHub API endpoints. You can set each to read, write (which includes read), or none. Here's what they control:
actions — Manage workflow runs (cancel, re-run, view logs)attestations — Generate and verify artifact attestations for build provenancechecks — Create and update check runs and check suitescontents — Read commits, create releases, push tags, manage repo filesdeployments — Create and manage deployment statusesid-token — Request an OIDC JWT for cloud provider authentication (write-only, no read)issues — Create, edit, close issues and commentspackages — Upload and publish to GitHub Packages (npm, Docker, Maven, etc.)pages — Manage GitHub Pages deploymentspull-requests — Comment on, approve, or merge pull requestssecurity-events — Read and write code scanning alerts and SARIF uploadsdiscussions — Close or delete GitHub DiscussionsMost CI jobs only need contents: read to check out code and run tests. Granting anything beyond that without a clear reason is giving away access you don't need to give.
The strongest approach is setting permissions at the job level rather than the workflow level. Each job gets exactly the scopes it needs, and nothing more. Here's a real-world workflow with three jobs that each declare their own permissions:
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
release:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
label-pr:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/labeler@v5Look at what each job can and can't do:
test job can read code. It can't push commits, publish packages, or comment on PRs. If a test dependency gets compromised, the blast radius is minimal.release job can write contents and packages because it needs to publish. It only runs on main, after tests pass. It can't touch issues or PRs.label-pr job can write to pull requests. It can't read repo contents beyond what's needed, can't push code, can't publish.Compare that to a workflow with permissions: write-all at the top, where every job can do everything. A compromised labeler action could push code to main.
This is the single most dangerous footgun in GitHub Actions, and it catches experienced teams.
pull_request events from forks run with read-only permissions and no access to secrets. That's safe. pull_request_target was designed for a different use case: it runs in the context of the base repository, with the base repo's secrets and a read/write GITHUB_TOKEN, even when the PR comes from a fork.
The intended use is labeling PRs or posting comments without needing to trust the fork's code. The problem happens when a workflow checks out and executes the PR's code:
# DANGEROUS: do not do this
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test # executes attacker-controlled codeThis is called a "pwn request" attack. The workflow checks out the attacker's code from the fork, then runs it with the base repo's elevated token and secrets. The attacker can exfiltrate secrets, push malicious code to your default branch, or publish compromised packages.
The correct pattern when you need pull_request_target is to never check out or execute the PR's head ref. Only use it for metadata operations:
# Safe: only reads PR metadata, never checks out fork code
on: pull_request_target
jobs:
label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/labeler@v5If you genuinely need to build fork code and then perform a privileged action (like posting a coverage comment), split it into two workflows. The first uses pull_request to build and upload artifacts. The second uses workflow_run to download those artifacts and post results with elevated permissions, never executing the fork's code directly.
Even with locked-down GITHUB_TOKEN permissions, many workflows still store long-lived AWS keys, GCP service account JSON, or Azure credentials as repository secrets. If those leak, an attacker has persistent access to your cloud infrastructure.
GitHub Actions supports OpenID Connect (OIDC) as an alternative. Instead of storing a secret, your workflow requests a short-lived JWT from GitHub's OIDC provider and exchanges it with your cloud provider for temporary credentials. The JWT includes claims about the repository, branch, workflow, and job, so your cloud provider's trust policy can restrict access precisely.
To use OIDC, you need the id-token: write permission in the job. Here's an AWS deployment example:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- run: aws s3 sync ./dist s3://my-bucketNo AWS_ACCESS_KEY_ID stored anywhere. The aws-actions/configure-aws-credentials action handles the OIDC token exchange automatically. On the AWS side, you create an IAM OIDC identity provider for token.actions.githubusercontent.com and a role with a trust policy that restricts the sub claim to your specific repo and branch.
The same approach works for GCP (Workload Identity Federation) and Azure (federated identity credentials). If your cloud provider supports OIDC, there's no reason to keep static keys in your repository secrets.
If you've got existing workflows that haven't been locked down, here's a practical checklist:
permissions key is using the repo default. Any workflow with permissions: write-all needs to be refactored.id-token: write and a trust policy bound to your repo.The whole point is defense in depth. Restricted defaults mean a missing permissions key is safe. Job-level scoping limits blast radius. OIDC eliminates credential theft. SHA pinning prevents supply chain attacks on actions themselves. None of these alone is sufficient. Together, they make your CI pipeline genuinely hard to exploit.

From Provision to Shutdown: The Lifecycle of a Tenki Runner

What Are GitHub Actions Runners? A Complete Beginner’s Guide to CI/CD Workflows

How to Migrate from GitHub-hosted Runners and Save on Cloud Costs in 3 Minutes
Get Tenki
Change 1 line of YAML for faster runners. Install a GitHub App for AI code reviews. No credit card, no contract. Takes about 2 minutes.