Applying the principle of least privilege in GitHub Actions, from tokens with minimal scopes, restricting workflows to specific branches, to protecting secrets and dependency security scanning so the release pipeline doesn't become an attacker's entry point.

In episode 11 the release workflow was fenced in with branch conditions. Now it's time to lock the other doors. The release pipeline is the most sensitive part of a repository: it can create tags, publish packages, and hold valuable tokens. One permission mistake and the whole ecosystem can be breached through the most unexpected path.
This episode covers GitHub Actions security best practices: least privilege with minimally scoped tokens, restricting workflows to specific branches, secret management, and dependency security scanning.
Least privilege means: give as little access as possible, only what the job needs, and only for as long as needed. A lint job needs no write access at all. A release job needs to write tags and publish packages — but doesn't need access to anything else.
| Token | Origin | Scope | Used for |
|---|---|---|---|
GITHUB_TOKEN | automatic per job, expires | repository scope | checkout, tags, GitHub release |
NPM_TOKEN | GitHub secret | publish scope on the registry | publishing packages to npm |
GITHUB_TOKEN is created automatically by GitHub for each job and expires when the workflow finishes — no need to store it as a persistent secret. In contrast, NPM_TOKEN is a real token you must create and store.
Warning
Distinguish the two tokens' scopes. GITHUB_TOKEN only applies to one
repository and expires with the job, so it's safe for most jobs.
NPM_TOKEN should be created with a dedicated publish scope on the
account actually assigned to releases, not a token with full access to
the entire account. Never print token values in logs, step names, or
artifacts, and never put them in a workflow without secret encryption.
Since early 2023, new repositories use a read-only default, but many older repositories still run with broader defaults. Always declare permissions explicitly so behavior doesn't depend on organization settings.
Non-release jobs only need contents: read:
name: CI
on:
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lintThe release job raises the scope, and only for what it needs:
jobs:
release:
runs-on: ubuntu-latest
environment: production
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Semantic Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-releaseTwo important details: environment: production places the job under an environment that can be guarded by reviewers, and persist-credentials: false keeps the token from attaching to git config so subsequent steps don't inherit credentials.
Tip
Use environment for production releases. In Settings > Environments,
configure the production environment with "Required reviewers" — the
release job won't run until the reviewers approve. Secrets can also be
scoped per environment so the production token doesn't leak into other
environments.
Restrict the trigger in the on block so the workflow only ever runs on the intended branches:
name: Release
on:
push:
branches:
- main
- stagingWith this, a push to feature/* or a pull request from a fork will never run the release job. For a release workflow, this is the first and simplest line of defense.
Warning
Be careful with pull_request_target. This workflow runs with the base
branch context and repository secrets, so a pull request from a fork can
execute code from a foreign contributor with wider access. Unless it's
genuinely necessary, don't use pull_request_target for jobs that
execute code from a PR.
Enable Dependabot for automatic update pull requests:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"Complete it with scheduled scanning in CI:
name: Security Scan
on:
schedule:
- cron: '0 6 * * 1'
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm audit --audit-level=high
codeql:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
- uses: github/codeql-action/analyze@v3npm audit catches vulnerabilities in direct and transitive dependencies; CodeQL catches security bugs in code. Notice the CodeQL job raises the security-events: write scope only for itself — a real-world example of least privilege within one workflow.
| Mistake | Symptom | Solution |
|---|---|---|
permissions: write-all | Token can do anything | Minimal scope per job |
| Secret leaks into logs | Token value readable | Don't echo, use encrypted env |
pull_request_target for release | A fork can trigger a release | Restrict branches or avoid that event |
In episode 12 you:
permissions, non-release contents: read, release contents: write and packages: write.on block so it only runs on the right branches.environment and "Required reviewers".npm audit, and CodeQL, with a publish-scoped NPM_TOKEN as your most valuable asset.In episode 13 we'll complete the defenses with Protected Branches & PR Policies — branch protection rules, required status checks, and mandatory reviewers for main and rc. See you in episode 13!