Skip to main content
Back to blog
April 2, 2026·Updated May 2, 2026|11 min read|Antoine Duno|Devops Security

How to Add an Automated Security Check to Your GitHub Actions Workflow

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.

Antoine Duno

1,651 words

AD

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.

yaml
# .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 ***.

bash
# 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.

yaml
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
          fi

Notice 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.

yaml
      - 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:

json
// .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:

yaml
      - name: Snyk vulnerability scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

CodeQL — Microsoft''s static analysis engine, free for open source, runs directly on your source code:

yaml
      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: javascript, typescript

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3

Trivy — scans container images for OS-level CVEs:

yaml
      - name: Scan Docker image
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: your-image:${{ github.sha }}
          severity: CRITICAL,HIGH
          exit-code: 1

These 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:

yaml
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.

Related articles

Keep reading