What you will build
The example is a Node.js service, contoso/payments-api, packaged as a container image in GitHub Container Registry and deployed to Azure Container Apps. Swap the build and deploy commands for your stack; the security structure stays the same.
payments-api/ ├── .github/ │ ├── workflows/ │ │ ├── ci.yml # pull requests + main: test, SAST, SCA, IaC → merge gate │ │ └── release.yml # main only: build, scan, SBOM, attest, deploy → release gate │ ├── dependabot.yml # keeps SHA pins and packages current │ └── CODEOWNERS # platform-security owns pipeline files ├── Dockerfile └── src/
Two workflows, not one, is deliberate. ci.yml runs untrusted pull request code, so it gets a read-only token and no secrets. release.yml only runs after merge to main, so it can hold write access to the registry and the production identity.
Step 1Harden the organization and repository
These settings change once and protect every workflow. Do them before writing YAML, because a well-written workflow in a permissive organization is still exposed.
! to block a specific action or version outright when one is reported compromised.
ci status checks; require code scanning results with no high-severity alerts.
production environment with reviewers
Settings›Environments›production
Add required reviewers and enable "Prevent self-review". Limit deployment branches to main. Store the Azure identifiers here as environment variables, so only jobs that target this environment can see them.
Then give pipeline files an owner, so no workflow change merges without a platform-security review:
# Pipeline definitions and their exceptions need a platform-security review
/.github/workflows/ @contoso/platform-security
/.github/dependabot.yml @contoso/platform-security
/.github/CODEOWNERS @contoso/platform-security
/.trivyignore.yaml @contoso/platform-security
Step 2Lock down every job
Every job in this tutorial follows the same skeleton. Learn it once and the full workflows read easily.
permissions: {} # workflow level: no token scopes at all
jobs:
example:
runs-on: ubuntu-24.04 # a fixed image, not ubuntu-latest, so upgrades are deliberate
timeout-minutes: 15 # a hung or hijacked job cannot run for 6 hours
permissions:
contents: read # opt in to exactly what this job needs
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # don't leave the token in .git/config for later steps
- Empty permissions at the top, grants per job. A job with
permissions: {}gets a token that can do nothing. Each job then lists only the scopes it uses, such assecurity-events: writeto upload scan results orid-token: writeto request an OIDC token. - Full commit SHA plus a version comment. A SHA cannot be moved the way a tag can, which is what made the 2025 and 2026 tag-hijacking incidents possible. The trailing
# v7.0.1comment is how Dependabot and reviewers know what the SHA is. persist-credentials: false. By default checkout writes the token into the repository's git config, where any later step, including a compromised dependency, can read it.
Optional: watch network egress
To see which hosts each job contacts, add step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 as the first step with egress-policy: audit. Once you know the normal endpoints, switch to block with an allowlist so a stealer cannot call home.
Step 3The CI workflow: your merge gate
ci.yml runs on every pull request, on pushes to main, and weekly so new CVEs surface on code that has not changed. It has four jobs that run in parallel. Mark all four as required status checks in the ruleset; that is what turns them into a gate.
| Job | Gate type | Token scopes | Fails when |
|---|---|---|---|
test | Quality | contents: read | Tests fail |
codeql | SAST | contents: read, security-events: write | Ruleset sees a new high alert |
dependencies | SCA + license | contents: read | New dependency with a high CVE or a license not on the list |
iac | IaC | contents: read, security-events: write | Critical or high misconfiguration |
SAST with CodeQL, including your workflows
The matrix runs CodeQL twice: once for the application language and once for actions, which analyzes the workflow files themselves for script injection and risky triggers. build-mode: none works for interpreted languages and for Java and C#; compiled languages that need a build use autobuild or manual.
codeql:
name: SAST (CodeQL)
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
security-events: write # upload results to code scanning
strategy:
fail-fast: false
matrix:
language: [javascript-typescript, actions]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: none
queries: security-extended
- uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: /language:${{ matrix.language }}
CodeQL uploads results; the ruleset's "Require code scanning results" rule decides whether the pull request can merge. Set it to block on High or higher security alerts to match the threshold policy in Foundations.
SCA and license policy with dependency review
Dependency review compares the dependency graph of the pull request with its base branch and fails only on what the pull request adds. That makes it a strict gate that does not punish authors for existing debt, which Dependabot alerts track separately.
dependencies:
name: SCA (dependency review)
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC
IaC scanning with Trivy
Trivy's config scan checks Terraform, Dockerfiles, Kubernetes manifests and more. Results go to code scanning as SARIF, so they appear next to CodeQL alerts. Note the two pins: the action by SHA and the Trivy binary by version.
iac:
name: IaC scan (Trivy)
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Scan Terraform, Dockerfiles and manifests
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: config
scan-ref: .
severity: CRITICAL,HIGH
exit-code: "1"
format: sarif
output: trivy-iac.sarif
limit-severities-for-sarif: true
version: v0.74.0 # pin the scanner binary as well
- name: Upload findings to code scanning
if: ${{ !cancelled() }} # upload even when the scan failed the job
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: trivy-iac.sarif
category: trivy-iac
Incident · March 2026
Attackers with stolen credentials force-pushed 76 of 77 trivy-action version tags to a credential stealer (CVE-2026-33634). Workflows that referenced @0.34.2 or similar ran it; workflows pinned to a commit SHA did not. The SHA above is release v0.36.0, published after the incident. Pinning protects you from a tag being moved, so keep reviewing what a new SHA contains before you accept the update.
Secret scanning is not a job here because GitHub runs it on every push when Secret Protection is on, and push protection blocks the push before a secret reaches the repository. If you do not have Secret Protection, add a gitleaks job pinned the same way.
Step 4Release: build once, scan, attest
release.yml runs only on main. It builds the image once, refers to it by digest from then on, and records two signed attestations: SLSA build provenance and an SBOM. The image scan is the release gate. If it fails, no attestation is created, and the deploy job's verification step will refuse the image.
build:
name: Build, scan, attest
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
packages: write # push to GHCR
id-token: write # Sigstore certificate for the attestations
attestations: write
artifact-metadata: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
- name: Scan image (release gate)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
severity: CRITICAL,HIGH
ignore-unfixed: true # block on what you can actually fix
exit-code: "1"
version: v0.74.0
- name: Generate SBOM
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
image: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
- name: Attest build provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
- name: Attest SBOM
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: sbom.spdx.json
push-to-registry: true
- Why scan after push? The image goes to the registry under a commit-SHA tag, but nothing deploys it unless it has an attestation, and attestations are only created after the scan passes. Building, scanning and pushing in separate steps works too; this order keeps one build and one digest.
actions/attestpicks its mode from the inputs. Withoutsbom-pathit creates SLSA provenance; with it, an SBOM attestation.actions/attest-build-provenancestill works and is now a thin wrapper around it.- No cache in release jobs. Leave out
cache:options here. A cache written by an earlier, less trusted job is an easy way to poison a release build.
Plan requirement
Artifact attestations work in public repositories on every plan. Private and internal repositories need GitHub Enterprise Cloud. Without it, sign the digest with cosign sign (keyless, using the job's OIDC token) and verify it with cosign verify in the deploy job.
Step 5Deploy with OIDC and approvals
The deploy job targets the production environment, so it waits for a required reviewer and can only run from main. It never holds a stored Azure secret: azure/login exchanges the job's OIDC token for a short-lived Azure token.
Create the trust in Microsoft Entra ID
On the app registration or user-assigned managed identity used for deploys, add a federated credential with these values. Scope the identity's Azure role to the one resource group it deploys to.
| Field | Value |
|---|---|
| Issuer | https://token.actions.githubusercontent.com |
| Subject | repo:contoso/payments-api:environment:production |
| Audience | api://AzureADTokenExchange |
Because the subject names the environment, only jobs that passed the environment's reviewers and branch rule can obtain the token. A pull request job, which has a pull_request subject, cannot.
deploy:
name: Deploy to production
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: production # required reviewers + main-only rule live here
permissions:
contents: read
id-token: write # OIDC token for Azure
attestations: read
packages: read
env:
IMAGE_REF: ghcr.io/contoso/payments-api@${{ needs.build.outputs.digest }}
steps:
- name: Verify provenance before deploying
env:
GH_TOKEN: ${{ github.token }}
run: |
gh attestation verify "oci://$IMAGE_REF" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml"
- name: Sign in to Azure with OIDC
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy the verified digest
run: |
az containerapp update \
--name payments-api \
--resource-group rg-payments-prod \
--image "$IMAGE_REF"
--signer-workflow matters. Without it, any workflow in the repository could produce a valid attestation. With it, only an image built by release.yml passes.
Step 6Keep pins fresh
SHA pinning only stays safe if updates keep flowing. Dependabot understands SHA-pinned actions and updates both the SHA and the version comment. A short cooldown means you skip versions that are pulled within days of release, which is when most hijacked releases are caught.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7 # let new releases age a week before proposing them
groups:
actions:
patterns: ["*"] # one PR for all action bumps
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
If you use Renovate instead, the helpers:pinGitHubActionDigests preset pins and updates actions the same way. For a weekly posture report, add the OpenSSF Scorecard workflow (ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4); it checks for pinned dependencies, token permissions, branch protection and more.
Step 7Scale across repositories
Copying ci.yml into fifty repositories gives you fifty files to keep secure. Move the security jobs into a reusable workflow in a central repository owned by the platform team, and call it by SHA.
name: ci
on:
pull_request:
push:
branches: [main]
permissions: {}
jobs:
secure-ci:
# Replace with the commit SHA of the release you reviewed; keep the tag as a comment.
uses: contoso/platform-workflows/.github/workflows/secure-ci.yml@REPLACE_WITH_COMMIT_SHA # v3.2.0
permissions:
contents: read
security-events: write
with:
language: javascript-typescript
- Permissions only shrink. A called workflow can never get more than the caller grants, so the caller's
permissionsblock is the ceiling. - Pass secrets by name. Avoid
secrets: inherit, which hands every secret the caller can see to the called workflow. - Enforce it. An organization ruleset can require this workflow to pass before merging in every repository, so teams cannot delete it from their own
ci.yml. - Higher provenance assurance. When signing happens inside a centrally owned reusable workflow, the build steps of the calling repository cannot forge the provenance. That is the basis for SLSA Build Level 3 on GitHub.
Pitfalls that get pipelines owned
Script injection through expressions
${{ }} is expanded into the script text before the shell runs it. Anything a contributor controls, such as titles, branch names, commit messages and issue bodies, must reach the shell as an environment variable.
- name: Greet
run: echo "Checking PR ${{ github.event.pull_request.title }}"
- name: Greet
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Checking PR $PR_TITLE"
pull_request_target and workflow_run
Both run with the base repository's token, secrets and cache access, even when a fork triggered them. Checking out and running the fork's code in that context is the classic "pwn request". Since June 2026, actions/checkout v7 refuses to check out a fork pull request's code in these events (the fix was backported to earlier majors), and the opt-out input is deliberately named allow-unsafe-pr-checkout so reviewers notice it. The block does not cover fetching the code with git or gh in a run step, so the rule stands: build untrusted code with pull_request, and treat anything it produces as data in privileged follow-up workflows.
Writing untrusted data to GITHUB_ENV
Lines appended to $GITHUB_ENV become environment variables for every later step. If any part comes from untrusted input, an attacker can set variables such as NODE_OPTIONS or LD_PRELOAD and run code in the next step. Use step outputs for untrusted values instead.
Self-hosted runners
Never attach persistent self-hosted runners to public repositories; any fork pull request can run code on them. For private repositories, use ephemeral just-in-time runners or Actions Runner Controller, put them in runner groups limited to specific repositories, and keep production-capable runners out of pull request jobs.
Complete files
Both workflows assembled, ready to adapt. Replace contoso/payments-api, the resource group and the build commands with your own.
# Runs untrusted pull request code: read-only token, no secrets.
name: ci
on:
pull_request:
branches: [main]
push:
branches: [main]
schedule:
- cron: "17 3 * * 1" # weekly rescan: new CVEs land on unchanged code
permissions: {} # nothing by default; each job opts in
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
name: Build and unit test
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci --ignore-scripts # dependency install scripts don't run
- run: npm test
codeql:
name: SAST (CodeQL)
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [javascript-typescript, actions]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: none
queries: security-extended
- uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: /language:${{ matrix.language }}
dependencies:
name: SCA (dependency review)
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC
iac:
name: IaC scan (Trivy)
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: config
scan-ref: .
severity: CRITICAL,HIGH
exit-code: "1"
format: sarif
output: trivy-iac.sarif
limit-severities-for-sarif: true
version: v0.74.0
- if: ${{ !cancelled() }}
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: trivy-iac.sarif
category: trivy-iac
# Trusted: runs only after merge to main.
# Build once, scan, attest provenance + SBOM, then deploy the verified digest.
name: release
on:
push:
branches: [main]
permissions: {}
concurrency:
group: release-main
cancel-in-progress: false # never cancel a deploy half-way
env:
IMAGE: ghcr.io/contoso/payments-api # registry paths must be lowercase
jobs:
build:
name: Build, scan, attest
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
- name: Scan image (release gate)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
version: v0.74.0
- uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
image: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: sbom.spdx.json
push-to-registry: true
deploy:
name: Deploy to production
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: production
permissions:
contents: read
id-token: write
attestations: read
packages: read
env:
IMAGE_REF: ghcr.io/contoso/payments-api@${{ needs.build.outputs.digest }}
steps:
- name: Verify provenance
env:
GH_TOKEN: ${{ github.token }}
run: |
gh attestation verify "oci://$IMAGE_REF" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml"
- uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy the verified digest
run: |
az containerapp update \
--name payments-api \
--resource-group rg-payments-prod \
--image "$IMAGE_REF"
Checked
Every SHA on this page was resolved from the upstream release tag on 11 October 2026. When you copy a file later, let Dependabot raise the update PRs rather than editing SHAs by hand.
References
- Security hardening for GitHub Actions
- Actions policy: blocking and SHA pinning
- Safer pull_request_target defaults for actions/checkout
- actions/attest and using artifact attestations
- Configuring OpenID Connect in Azure
- Trivy security advisories
Next: the same pipeline on Azure DevOps or the hardening checklist.