Antoine Duno
Founder of ZeriFlow · 10 years fullstack engineering · About the author
Key Takeaways
- Shipping insecure code to production because no one ran a security check is a solved problem. This guide walks through adding a fully automated security check step to your GitHub Actions workflow — with real YAML, score-based build failures, and secrets management best practices.
- Includes copy-paste code examples and step-by-step instructions.
- Free automated scan available to verify your implementation.
How to Add an Automated Security Check to Your GitHub Actions Workflow
Every senior engineer has a war story that starts with "it was working in staging." A misconfigured HTTP header, a leaked API key in a build artifact, or a dependency with a known CVE — all shipped silently because the CI pipeline only checked if the tests passed. Adding a security check to your GitHub Actions workflow takes about 20 minutes and catches an entire category of problems before they reach production.
This guide is practical and opinionated. You will end up with a working YAML file by the end.
Why the Build Should Fail on Security Issues
Test failures block merges. Linting failures block merges. But security issues? Most teams still treat them as advisory — a report someone glances at once a quarter. That is how you end up with a CVE that has been in your dependency tree for 14 months.
The fix is to treat a failing security score the same way you treat a failing unit test: the build is broken, the PR is blocked, and someone has to fix it before the code ships.
GitHub Actions makes this straightforward. You run a security scan as a step, check the returned score against a threshold, and exit with a non-zero code if the score is too low. GitHub interprets a non-zero exit as a failed check. Branch protection rules do the rest.
The Core Workflow: ZeriFlow + GitHub Actions
ZeriFlow exposes a REST endpoint at POST https://api.zeriflow.com/scan-quick. You send a URL, you get back a full JSON report including a /100 security score. The whole scan runs in under 60 seconds and covers 80+ checks: security headers, TLS configuration, exposed sensitive files, open redirects, and more.
Here is a complete, production-ready workflow file.
# .github/workflows/security-check.yml
name: Security Check
on:
push:
branches: [main, staging]
pull_request:
branches: [main]
env:
SCORE_THRESHOLD: 75
SCAN_URL: https://your-staging-url.com # override per environment below
jobs:
security-scan:
name: ZeriFlow Security Scan
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run ZeriFlow security scan
id: scan
env:
ZERIFLOW_API_KEY: ${{ secrets.ZERIFLOW_API_KEY }}
run: |
RESPONSE=$(curl -s -w "\\n%{http_code}" \\
-X POST https://api.zeriflow.com/scan-quick \\
-H "Content-Type: application/json" \\
-H "X-API-Key: $ZERIFLOW_API_KEY" \\
-d "{\\"url\\": \\"${SCAN_URL}\\"}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed ''$d'')
if [ "$HTTP_CODE" != "200" ]; then
echo "Scan API returned HTTP $HTTP_CODE — failing build"
echo "$BODY"
exit 1
fi
SCORE=$(echo "$BODY" | jq -r ''.score'')
echo "score=$SCORE" >> "$GITHUB_OUTPUT"
echo "report=$BODY" >> "$GITHUB_OUTPUT"
echo "Security score: $SCORE / 100"
echo "$BODY" | jq ''.findings[] | select(.severity == "critical" or .severity == "high") | .title''
- name: Enforce score threshold
run: |
SCORE="${{ steps.scan.outputs.score }}"
THRESHOLD="${{ env.SCORE_THRESHOLD }}"
if (( $(echo "$SCORE < $THRESHOLD" | bc -l) )); then
echo "FAIL: Security score $SCORE is below threshold $THRESHOLD"
exit 1
fi
echo "PASS: Security score $SCORE meets threshold $THRESHOLD"
- name: Post score to PR comment
if: github.event_name == ''pull_request''
uses: actions/github-script@v7
with:
script: |
const score = ''${{ steps.scan.outputs.score }}'';
const threshold = process.env.SCORE_THRESHOLD;
const status = parseFloat(score) >= parseFloat(threshold) ? ''✅ PASS'' : ''❌ FAIL'';
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `### ZeriFlow Security Check\\n\\n**Score:** ${score}/100\\n**Threshold:** ${threshold}/100\\n**Result:** ${status}\\n\\n[View full report on ZeriFlow](https://zeriflow.com)`
});Save this file to .github/workflows/security-check.yml in your repository. The ZERIFLOW_API_KEY secret needs to be configured in Settings > Secrets and variables > Actions before the first run.
Managing the API Key Safely
Never put your API key directly in the YAML file. GitHub Actions secrets are the right mechanism. The key is masked in logs — if it ever appears in a log line, GitHub replaces it with ***.
# Add via GitHub CLI
gh secret set ZERIFLOW_API_KEY --body "zf_live_xxxxxxxxxxxxxxxxxx"
# Or scope it to a specific environment
gh secret set ZERIFLOW_API_KEY --env production --body "zf_live_xxxxxxxxxxxxxxxxxx"Environment-scoped secrets are useful when you want to use different API keys per environment or require a manual approval step before the production scan runs.
For self-hosted runners, also ensure the runner''s execution environment does not persist environment variables between jobs. Use env: blocks scoped to individual steps rather than top-level env: blocks for sensitive values.
Matrix Builds: Scan Multiple Environments in Parallel
If you deploy to multiple environments — a preview URL per PR, a staging URL, a canary deployment — you can scan all of them simultaneously using a matrix strategy.
jobs:
security-scan:
name: Security Scan (${{ matrix.environment }})
runs-on: ubuntu-latest
strategy:
matrix:
include:
- environment: staging
url: https://staging.your-app.com
threshold: 70
- environment: production
url: https://your-app.com
threshold: 80
fail-fast: false # let all scans complete even if one fails
steps:
- name: Run ZeriFlow scan
id: scan
env:
ZERIFLOW_API_KEY: ${{ secrets.ZERIFLOW_API_KEY }}
run: |
RESPONSE=$(curl -s \\
-X POST https://api.zeriflow.com/scan-quick \\
-H "Content-Type: application/json" \\
-H "X-API-Key: $ZERIFLOW_API_KEY" \\
-d "{\\"url\\": \\"${{ matrix.url }}\\"}")
SCORE=$(echo "$RESPONSE" | jq -r ''.score'')
echo "[${{ matrix.environment }}] Score: $SCORE / 100"
if (( $(echo "$SCORE < ${{ matrix.threshold }}" | bc -l) )); then
echo "FAIL: $SCORE < ${{ matrix.threshold }} for ${{ matrix.environment }}"
exit 1
fiNotice that fail-fast: false is intentional here. You want to see the scores for all environments even when one fails, so you can prioritize which issues to fix first.
Caching Scan Results
For teams running frequent short-lived branches, you may want to cache scan results to avoid burning through your API quota on identical deployments. Use the cache action keyed on a hash of your security-relevant configuration files.
- name: Restore scan cache
id: cache-scan
uses: actions/cache@v4
with:
path: .zeriflow-cache
key: zeriflow-${{ hashFiles(''next.config.js'', ''nginx.conf'', ''Caddyfile'', ''.env.example'') }}-${{ github.sha }}
restore-keys: |
zeriflow-${{ hashFiles(''next.config.js'', ''nginx.conf'', ''Caddyfile'', ''.env.example'') }}-
- name: Run scan (if cache miss)
if: steps.cache-scan.outputs.cache-hit != ''true''
run: |
# run the scan and save to .zeriflow-cache/result.json
mkdir -p .zeriflow-cache
curl -s \\
-X POST https://api.zeriflow.com/scan-quick \\
-H "X-API-Key: ${{ secrets.ZERIFLOW_API_KEY }}" \\
-H "Content-Type: application/json" \\
-d "{\\"url\\": \\"$SCAN_URL\\"}" \\
> .zeriflow-cache/result.json
- name: Evaluate cached or fresh result
run: |
SCORE=$(jq -r ''.score'' .zeriflow-cache/result.json)
echo "Score: $SCORE"This is appropriate for monorepos where many PRs touch application logic but not server configuration. If you are changing nginx.conf or adding a new environment variable, the cache key changes and a fresh scan runs.
Handling False Positives and Exceptions
Some findings are intentional. A security scanner may flag an endpoint that intentionally has no authentication, or a header that is deliberately absent because it conflicts with an embedded widget. You need a way to acknowledge a finding without silently ignoring it.
One clean pattern is an allowlist file committed to the repository:
// .zeriflow-exceptions.json
{
"acknowledged": [
{
"check": "x-frame-options",
"reason": "Embedded in partner iframes — SAMEORIGIN would break integration",
"expires": "2026-09-01",
"approved_by": "platform-team"
}
]
}Your CI script can read this file and subtract acknowledged findings from the score computation before comparing against the threshold. Any exception with an expires date in the past should be treated as a hard failure — it forces regular review rather than letting exceptions accumulate indefinitely.
Alternative Approaches
ZeriFlow scans the deployed URL, which means it catches runtime configuration issues (missing headers, TLS problems, exposed files) that static analysis tools cannot see. For a complete picture, you should also run:
Snyk GitHub Action — scans your dependency tree for known CVEs:
- name: Snyk vulnerability scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=highCodeQL — Microsoft''s static analysis engine, free for open source, runs directly on your source code:
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript, typescript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3Trivy — scans container images for OS-level CVEs:
- name: Scan Docker image
uses: aquasecurity/trivy-action@master
with:
image-ref: your-image:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1These tools are complementary, not competitive. CodeQL sees your source. Snyk sees your dependencies. Trivy sees your container. ZeriFlow sees your deployed application. A mature CI pipeline runs all four.
Putting It All Together: A Full DevSecOps Job
Here is a consolidated job that combines dependency scanning with an external security scan, runs them in parallel, and requires both to pass before merging:
jobs:
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
external-security-scan:
runs-on: ubuntu-latest
steps:
- name: ZeriFlow scan
env:
ZERIFLOW_API_KEY: ${{ secrets.ZERIFLOW_API_KEY }}
run: |
SCORE=$(curl -s \\
-X POST https://api.zeriflow.com/scan-quick \\
-H "X-API-Key: $ZERIFLOW_API_KEY" \\
-H "Content-Type: application/json" \\
-d ''{"url": "https://staging.your-app.com"}'' | jq -r ''.score'')
[ $(echo "$SCORE >= 75" | bc) -eq 1 ] || exit 1
all-security-checks:
needs: [dependency-scan, external-security-scan]
runs-on: ubuntu-latest
steps:
- run: echo "All security checks passed"The all-security-checks job is the one you add to your branch protection required status checks. It only passes when both upstream jobs pass. Engineers cannot merge a PR without a green security check — not because of policy, but because the merge button is physically disabled.
Conclusion
A security check that lives outside CI is a suggestion. One that lives inside CI and controls the merge button is a requirement. The YAML in this guide is production-ready — copy it, add your ZERIFLOW_API_KEY secret, adjust the score threshold to match your team''s risk tolerance, and commit it.
The first time it catches a misconfigured security header before it ships to production, the 20-minute setup will look like a very good investment.
ZeriFlow''s free tier gives you 3 scans per day with no credit card required. Run your first scan at zeriflow.com to see your current score before you add the CI check.
Add security scanning to your CI/CD pipeline.
Catch vulnerabilities before they reach production.