A platform team ships a Node.js service forty times a week through GitHub Actions, and last quarter a transitive dependency with a known remote-code-execution CVE rode a routine pull request straight into production — nobody looked, because nothing made them look. The mandate that came down from the CISO is blunt: no pull request merges with a fixable high-severity vulnerability in it, and that gate has to be automatic, visible on the PR, and impossible to skip by forgetting. This guide builds exactly that. You will wire Snyk — for software composition analysis (SCA) of open-source dependencies, for container base-image scanning, and for infrastructure-as-code (IaC) misconfiguration checks — into GitHub Actions so that every PR is gated on severity, every default branch is continuously monitored for newly disclosed vulnerabilities, and the Snyk token that makes it all work never sits in a plaintext CI variable waiting to leak.
This is the per-repository, developer-facing layer of a defense-in-depth program. It sits underneath the platform-wide posture tools — Wiz / Wiz Code scanning the cloud accounts and the IaC at the org level, CrowdStrike Falcon doing runtime protection on the running workloads — and it is deliberately the cheapest place to catch a vulnerability: in the PR, before the image is ever built, while the developer who introduced it is still looking at the diff.
Prerequisites
- A Snyk account (Team or Enterprise tier for org-level policy and the PR Checks UI) and an organization ID — grab it from
https://app.snyk.iounder Settings → General. - A GitHub repository on GitHub Actions, with admin rights to configure branch protection and repository secrets.
- The application code building cleanly — Snyk SCA needs a resolvable manifest (
package-lock.json,pom.xml,go.mod,requirements.txt, etc.) and runs best afternpm ci/mvn installso the full dependency graph exists. - A
Dockerfileand a built image for the container scan; Terraform or Kubernetes manifests for the IaC scan. - The Snyk CLI locally (
npm install -g snykorbrew install snyk) for the one-time baseline run and for debugging. - Optional but recommended: HashiCorp Vault reachable from your runners, to broker the Snyk token instead of storing it as a long-lived GitHub secret.
Target topology
The flow is a single pull request fanning into three parallel Snyk scans, each of which can fail the PR, plus a fourth job that runs only on the default branch to keep an ongoing watch:
- A developer opens a pull request in GitHub. They authenticated to GitHub through the workforce IdP — Okta federated to Entra ID — so the identity on the commit and the review is the same one the rest of the stack trusts; nobody pushes as an anonymous bot.
- GitHub Actions runs three gating jobs in parallel on the PR:
snyk testfor open-source SCA against the manifest,snyk container testagainst the freshly built image plus itsDockerfile, andsnyk iac testagainst the Terraform / Kubernetes files. Any one of them exiting non-zero on a high-severity, fixable issue turns the PR check red, and branch protection makes that check a required status — so merge is physically blocked. - The runners get the
SNYK_TOKENnot from a static secret but from HashiCorp Vault via thehashicorp/vault-actionGitHub Action, which exchanges the workflow’s GitHub OIDC token for a short-lived Vault token and reads the Snyk service-account credential. The token lives for the length of the job and is never written to the repo. - On merge to the default branch, a separate
snyk monitorjob takes a snapshot of the dependency tree and pushes it to Snyk’s backend. From then on, when a brand-new CVE is disclosed against a dependency you already shipped, Snyk raises it asynchronously — wired to open a ServiceNow change/incident ticket and notify the team — without any code change triggering it. - The same Snyk results flow up into the broader program: Wiz Code correlates the IaC findings with what is actually deployed in the cloud for attack-path context, and the platform’s observability stack — Datadog or Dynatrace — ingests the scan metrics so “fixable highs open” and “mean time to remediate” become tracked SLOs, not numbers nobody sees.
The design principle throughout: shift the cheap check left, keep the expensive check running. SCA, container, and IaC scanning in the PR is the shift-left; snyk monitor on the default branch is the keep-running. You need both — a PR gate tells you about the vulnerability you are introducing today, and monitoring tells you about the one disclosed tomorrow in code you shipped last month.
1. Create a Snyk service account and store its token in Vault
Do not use a human’s Snyk API token for CI — it inherits that person’s access and dies when they leave. Create a dedicated service account scoped to one organization.
In the Snyk UI: Settings → Service accounts → Create a service account, role Org Collaborator, scoped to the target org. Copy the token once (it is shown only once).
Now put it in Vault rather than GitHub. Write it to a KV v2 path:
vault kv put secret/ci/snyk \
token="<the-snyk-service-account-token>" \
org_id="<your-snyk-org-id>"
Bind a Vault policy and a JWT/OIDC auth role so only this repo’s workflows can read it:
# snyk-ci-policy.hcl
path "secret/data/ci/snyk" {
capabilities = ["read"]
}
vault policy write snyk-ci snyk-ci-policy.hcl
# Trust GitHub's OIDC issuer
vault auth enable jwt
vault write auth/jwt/config \
oidc_discovery_url="https://token.actions.githubusercontent.com" \
bound_issuer="https://token.actions.githubusercontent.com"
# Only the kloudvin/blog-api repo, only on real branches, gets the policy
vault write auth/jwt/role/snyk-ci \
role_type="jwt" \
user_claim="actor" \
bound_claims_type="glob" \
bound_claims='{"repository":"kloudvin/blog-api"}' \
bound_audiences="https://github.com/kloudvin" \
policies="snyk-ci" \
ttl="15m"
This is the whole point of routing through HashiCorp Vault: the Snyk credential is leased for 15 minutes to a workflow that GitHub itself cryptographically vouched for, instead of sitting forever in a repo secret that anyone with write access — or a malicious dependency in the build — could exfiltrate.
If you are not running Vault yet, you can fall back to a GitHub repository secret named
SNYK_TOKEN(Settings → Secrets and variables → Actions → New repository secret). It works — but rotate it on a schedule and treat it as a stopgap.
2. Establish a local baseline before you gate anything
Never turn on a hard gate blind — you will block every PR on day one with a backlog of pre-existing issues nobody can fix in one sprint. Run the CLI locally first to see what you are dealing with:
export SNYK_TOKEN="<service-account-token>"
npm ci # resolve the full dependency graph first
snyk test --severity-threshold=high --all-projects
# Container: build, then scan the image + its Dockerfile
docker build -t blog-api:baseline .
snyk container test blog-api:baseline \
--file=Dockerfile --severity-threshold=high
# IaC: scan Terraform / k8s manifests
snyk iac test ./infra --severity-threshold=high
Read the output. For the inevitable pre-existing findings you cannot fix immediately, record a time-boxed ignore with justification — Snyk stores these in .snyk so they are reviewed in code, not hidden in a console:
snyk ignore --id=SNYK-JS-LODASH-1040724 \
--reason="No fixed upstream; compensating control in WAF" \
--expiry=2026-07-15
The expiry is non-negotiable — an ignore without an end date is a vulnerability you decided to keep forever. Commit the resulting .snyk file.
3. Add the SCA (open-source) gating job
Create .github/workflows/snyk-pr.yml. Start with the permissions block and the Vault step — every job will reuse this pattern to fetch the token.
name: Snyk PR gate
on:
pull_request:
branches: [main]
permissions:
contents: read
id-token: write # required for GitHub OIDC -> Vault
pull-requests: write # to annotate the PR
security-events: write # to upload SARIF to the Security tab
jobs:
sca:
name: SCA (open source)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch Snyk token from Vault
id: secrets
uses: hashicorp/vault-action@v3
with:
url: https://vault.internal.kloudvin.net
method: jwt
role: snyk-ci
secrets: |
secret/data/ci/snyk token | SNYK_TOKEN ;
secret/data/ci/snyk org_id | SNYK_ORG
- name: Setup Node and install deps
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Snyk Open Source test (gate on high)
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ steps.secrets.outputs.SNYK_TOKEN }}
with:
command: test
args: >-
--severity-threshold=high
--all-projects
--org=${{ steps.secrets.outputs.SNYK_ORG }}
--sarif-file-output=snyk-sca.sarif
- name: Upload SARIF to GitHub Security tab
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: snyk-sca.sarif
category: snyk-sca
Two deliberate choices. --severity-threshold=high means the job exits non-zero (failing the PR) only for high and critical issues — you are not blocking merges on every low-severity advisory, which is how you keep developer trust. And if: always() on the SARIF upload ensures findings land in the GitHub Security tab even when the gate step fails, so the developer sees what broke, inline on their code.
4. Add the container-image gating job
The container scan needs a built image, so it builds one in-pipeline, then scans both the image layers and the Dockerfile instructions (the latter is what catches “you’re on node:18 which has 40 OS CVEs — move to node:20-slim”).
container:
name: Container image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch Snyk token from Vault
id: secrets
uses: hashicorp/vault-action@v3
with:
url: https://vault.internal.kloudvin.net
method: jwt
role: snyk-ci
secrets: |
secret/data/ci/snyk token | SNYK_TOKEN
- name: Build image
run: docker build -t blog-api:${{ github.sha }} .
- name: Snyk Container test (gate on high)
uses: snyk/actions/docker@master
env:
SNYK_TOKEN: ${{ steps.secrets.outputs.SNYK_TOKEN }}
with:
image: blog-api:${{ github.sha }}
args: >-
--file=Dockerfile
--severity-threshold=high
--sarif-file-output=snyk-container.sarif
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: snyk-container.sarif
category: snyk-container
Container findings frequently resolve to a base-image upgrade, and Snyk tells you the best target in the output (Base Image node:18 → Recommended node:20-slim). Acting on that one line usually clears the majority of OS-package CVEs in a single commit.
5. Add the IaC misconfiguration gating job
Snyk IaC checks Terraform, CloudFormation, Kubernetes, and ARM/Bicep against a rule set (public S3 buckets, security groups open to 0.0.0.0/0, containers running as root, missing encryption). Point it at your infra directory.
iac:
name: IaC misconfig
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch Snyk token from Vault
id: secrets
uses: hashicorp/vault-action@v3
with:
url: https://vault.internal.kloudvin.net
method: jwt
role: snyk-ci
secrets: |
secret/data/ci/snyk token | SNYK_TOKEN
- name: Snyk IaC test (gate on high)
uses: snyk/actions/iac@master
env:
SNYK_TOKEN: ${{ steps.secrets.outputs.SNYK_TOKEN }}
with:
file: infra/
args: >-
--severity-threshold=high
--sarif-file-output=snyk-iac.sarif
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: snyk-iac.sarif
category: snyk-iac
This is the same misconfiguration class that Wiz Code evaluates org-wide, but catching it here — in the PR that writes the Terraform — is far cheaper than catching it after terraform apply has already opened the security group. The two are complementary: Snyk IaC blocks the bad config at the source; Wiz confirms nothing drifted post-deploy and supplies the cloud attack-path context Snyk cannot see.
6. Add the snyk monitor job on the default branch
Everything above gates changes. This job, which runs only on push to main, records the current dependency state so Snyk can alert you to vulnerabilities disclosed after merge — the class of risk a PR gate structurally cannot catch.
# .github/workflows/snyk-monitor.yml
name: Snyk monitor (default branch)
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch Snyk token from Vault
id: secrets
uses: hashicorp/vault-action@v3
with:
url: https://vault.internal.kloudvin.net
method: jwt
role: snyk-ci
secrets: |
secret/data/ci/snyk token | SNYK_TOKEN ;
secret/data/ci/snyk org_id | SNYK_ORG
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Snyk monitor (snapshot for ongoing alerts)
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ steps.secrets.outputs.SNYK_TOKEN }}
with:
command: monitor
args: --all-projects --org=${{ steps.secrets.outputs.SNYK_ORG }}
- name: Snyk container monitor
run: |
docker build -t blog-api:${{ github.sha }} .
snyk container monitor blog-api:${{ github.sha }} --file=Dockerfile
env:
SNYK_TOKEN: ${{ steps.secrets.outputs.SNYK_TOKEN }}
In the Snyk org settings, wire the notifications to your team channel and configure the ServiceNow integration so a newly disclosed critical against a monitored project auto-opens a change/incident ticket. That closes the loop: a vulnerability disclosed at 2 a.m. against a library you shipped in March becomes a tracked ticket by morning, not a surprise in next quarter’s pentest.
7. Make the checks required (the actual gate)
Workflows that can fail a PR do nothing until branch protection makes them required. This is the step people forget, and without it the whole exercise is advisory.
Via the GitHub CLI:
gh api -X PUT repos/kloudvin/blog-api/branches/main/protection \
-H "Accept: application/vnd.github+json" \
-f "required_status_checks[strict]=true" \
-f "required_status_checks[contexts][]=SCA (open source)" \
-f "required_status_checks[contexts][]=Container image" \
-f "required_status_checks[contexts][]=IaC misconfig" \
-F "enforce_admins=true" \
-F "required_pull_request_reviews[required_approving_review_count]=1" \
-F "restrictions=null"
enforce_admins=true matters: a gate that admins can click past is a gate that gets clicked past under deadline pressure. If you run an org-wide policy, prefer a GitHub ruleset so the same protection is enforced across every repo from one place rather than per-repository.
Validation
Prove the gate works in both directions — that it blocks bad PRs and passes clean ones — before you trust it.
# 1. Open a PR that introduces a known-vulnerable dependency
git checkout -b test/vuln
npm install lodash@4.17.15 # has a known prototype-pollution high
git commit -am "test: introduce vulnerable lodash" && git push -u origin test/vuln
gh pr create --fill
# 2. Watch the checks — the SCA job MUST go red and block merge
gh pr checks --watch
# 3. Confirm merge is blocked
gh pr merge --merge # expect: "Pull request is not mergeable"
Then verify the happy path and the monitoring side:
# Bump to a patched version; the same PR's checks should go green
npm install lodash@4.17.21 && git commit -am "fix: patch lodash" && git push
# Confirm the default-branch snapshot landed in Snyk
snyk monitor --all-projects # prints the project URL in app.snyk.io
Finally, open the GitHub Security → Code scanning tab and confirm SARIF findings from all three categories (snyk-sca, snyk-container, snyk-iac) appear inline on the diff. If the tab is empty, your upload-sarif step or security-events: write permission is missing.
Rollback / teardown
If the gate is too noisy on day one and blocking legitimate work, de-risk without ripping it out — drop it from required to advisory rather than deleting the scans:
# Loosen: remove the contexts from required checks (scans still run + report)
gh api -X PATCH repos/kloudvin/blog-api/branches/main/protection/required_status_checks \
-f "contexts[]="
To fully remove the integration:
# 1. Delete the workflow files
git rm .github/workflows/snyk-pr.yml .github/workflows/snyk-monitor.yml
git commit -m "chore: remove Snyk gating" && git push
# 2. Stop ongoing monitoring (deactivate the project in the Snyk UI, or):
snyk monitor --rem-from-monitor # where supported, else deactivate in app.snyk.io
# 3. Revoke the credential at the source — do not just delete the GitHub secret
# Snyk UI -> Settings -> Service accounts -> revoke 'ci-snyk'
# Vault:
vault kv destroy -versions=1 secret/ci/snyk
vault policy delete snyk-ci
Revoking the service account in Snyk and destroying the Vault secret is the part that actually matters — a deleted workflow with a live token still floating around is the leak you were trying to prevent.
Common pitfalls
- Scanning before
npm ci/mvn install. Snyk SCA needs the resolved dependency graph. Run the install step first, or the scan silently under-reports transitive dependencies and your gate has a blind spot. - Gating on every severity.
--severity-threshold=highis the sweet spot. Block onlowand developers will route around the gate within a week; the credibility of the gate is worth more than catching every informational finding. - Ignores without expiry.
snyk ignorewith no--expiryis a permanent exception masquerading as a temporary one. Always time-box, always justify, always commit the.snykfile so it is reviewed. - Forgetting
enforce_admins/ required status. The single most common reason “we have Snyk but vulns still ship” — the checks run, turn red, and merge happens anyway because the check was never marked required or an admin bypassed it. strictstatus checks on a fast-moving repo.strict=trueforces branches up to date withmainbefore merge; on a high-velocity repo this causes endless re-runs. Worth it for security gates, but know the tradeoff and tune CI concurrency accordingly.- Container scan with no
--file=Dockerfile. Omit it and you lose the base-image upgrade recommendation — the single most useful output for clearing OS-level CVEs. - A static
SNYK_TOKENsecret left in the repo after moving to Vault. Delete it; a forgotten long-lived token is exactly the credential-leak class this whole design exists to avoid.
Security notes
The architecture is built so the scanner’s own credential is never the weak link. The Snyk token is a scoped service account, leased for minutes from HashiCorp Vault in exchange for a GitHub OIDC assertion — no long-lived secret in CI to exfiltrate, and the human identity on every PR traces back through Okta → Entra ID SSO. Grant the workflow only the permissions it needs (contents: read, plus id-token/security-events/pull-requests: write), never a blanket write-all. And remember this gate’s scope: it is one layer. Wiz / Wiz Code owns org-wide cloud and IaC posture with attack-path analysis; CrowdStrike Falcon owns runtime protection on the live workloads; Snyk-in-the-PR owns the cheap, early catch. Defense in depth means the PR gate failing open is backstopped by the layers above it — but you still fix the PR gate.
Cost notes
The expensive line item is Snyk licensing, billed per contributing developer (the engineers whose commits trigger tests), so scope service accounts to real teams and prune inactive contributors — a stale ex-employee still counted against the seat count is pure waste. GitHub Actions runner minutes are the other cost: three scans per PR on a busy repo adds up, so run the three gating jobs in parallel (as above) rather than serially to cut wall-clock and developer wait, cache npm/mvn aggressively, and keep the heavyweight snyk monitor on the default branch only — not on every PR. The economics still favor this overwhelmingly: a few dollars of runner time and a developer seat is trivial against the cost of a single RCE reaching production and the incident response, customer notification, and audit that follow. Feed the scan metrics into Datadog or Dynatrace and track “fixable highs open” and mean-time-to-remediate as SLOs — the dashboard that proves the gate is paying for itself is the one that keeps it funded.