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

How to Block Pull Requests with Failing Security Checks (GitHub)

Passing security checks should be a non-negotiable merge requirement, not a polite suggestion. This guide shows you how to configure GitHub branch protection rules, write a security-gate workflow, and use ZeriFlow's CI/CD integration to block any PR that drops below your score threshold.

Antoine Duno

1,739 words

AD

Antoine Duno

Founder of ZeriFlow · 10 years fullstack engineering · About the author

Key Takeaways

  • Passing security checks should be a non-negotiable merge requirement, not a polite suggestion. This guide shows you how to configure GitHub branch protection rules, write a security-gate workflow, and use ZeriFlow's CI/CD integration to block any PR that drops below your score threshold.
  • Includes copy-paste code examples and step-by-step instructions.
  • Free automated scan available to verify your implementation.

How to Block Pull Requests with Failing Security Checks (GitHub)

Most security policies exist in a wiki that nobody reads. Required status checks exist in the GitHub UI — and they make the merge button go grey until every check passes. This is the difference between a policy and a control.

This guide explains how to configure GitHub to physically prevent merging a pull request until your security scan passes, using branch protection rules, required status checks, and ZeriFlow''s CI/CD integration.


How GitHub Status Checks Work

When a GitHub Actions workflow runs on a pull request, it reports a status check — a named pass/fail result that appears in the PR''s "Checks" tab. By default, these checks are advisory: the PR can be merged even when they fail.

You change that by configuring the check as required in your branch protection rules. Once required, GitHub disables the merge button until the check reports a passing result. Repository administrators can still override with a bypass, but engineers cannot merge through a failing security check by accident or by impatience.

The relationship between the pieces:

  1. 1A workflow YAML file defines a job with a specific name
  2. 2That job runs on the pull_request event and exits 0 (pass) or non-zero (fail)
  3. 3Branch protection rules list that job''s name under "Required status checks"
  4. 4GitHub enforces the requirement at merge time

Step 1: Write the Security Gate Workflow

The workflow needs to produce a status check with a consistent, predictable name. GitHub identifies checks by the job name, not the workflow file name.

yaml
# .github/workflows/security-gate.yml
name: Security Gate

on:
  pull_request:
    branches: [main, develop, release/*]
    types: [opened, synchronize, reopened]

jobs:
  security-gate:   # This exact name becomes the required status check
    name: Security Gate
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      pull-requests: write   # needed to post the PR comment
      statuses: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Determine preview URL
        id: url
        run: |
          # Adapt this to your deploy system.
          # Vercel: https://${{ github.event.repository.name }}-git-${{ github.head_ref }}.vercel.app
          # Netlify: use the deploy-preview URL from the netlify bot comment
          # Render: https://${{ github.event.repository.name }}-pr-${{ github.event.number }}.onrender.com
          echo "target=https://staging.your-app.com" >> "$GITHUB_OUTPUT"

      - name: Run ZeriFlow security scan
        id: scan
        env:
          ZERIFLOW_API_KEY: ${{ secrets.ZERIFLOW_API_KEY }}
          TARGET_URL: ${{ steps.url.outputs.target }}
        run: |
          echo "Scanning: $TARGET_URL"

          RESPONSE=$(curl -s -f \\
            -X POST https://api.zeriflow.com/scan-quick \\
            -H "Content-Type: application/json" \\
            -H "X-API-Key: $ZERIFLOW_API_KEY" \\
            -d "{\\"url\\": \\"$TARGET_URL\\"}" \\
            || echo ''{"error": "scan_failed"}'')

          if echo "$RESPONSE" | jq -e ''.error'' > /dev/null 2>&1; then
            echo "Scan failed. Check API key and URL."
            exit 1
          fi

          SCORE=$(echo "$RESPONSE" | jq -r ''.score'')
          CRITICAL=$(echo "$RESPONSE" | jq -r ''[.findings[] | select(.severity == "critical")] | length'')
          HIGH=$(echo "$RESPONSE" | jq -r ''[.findings[] | select(.severity == "high")] | length'')

          echo "score=$SCORE" >> "$GITHUB_OUTPUT"
          echo "critical=$CRITICAL" >> "$GITHUB_OUTPUT"
          echo "high=$HIGH" >> "$GITHUB_OUTPUT"
          echo "response<<EOF" >> "$GITHUB_OUTPUT"
          echo "$RESPONSE" >> "$GITHUB_OUTPUT"
          echo "EOF" >> "$GITHUB_OUTPUT"

          echo "Score: $SCORE/100 | Critical: $CRITICAL | High: $HIGH"

      - name: Enforce threshold
        env:
          SCORE: ${{ steps.scan.outputs.score }}
          CRITICAL: ${{ steps.scan.outputs.critical }}
          THRESHOLD: 75
        run: |
          # Block on critical findings regardless of overall score
          if [ "$CRITICAL" -gt 0 ]; then
            echo "BLOCKED: $CRITICAL critical finding(s) must be resolved before merging"
            exit 1
          fi

          if (( $(echo "$SCORE < $THRESHOLD" | bc -l) )); then
            echo "BLOCKED: Score $SCORE < threshold $THRESHOLD"
            exit 1
          fi

          echo "APPROVED: Score $SCORE >= $THRESHOLD and no critical findings"

      - name: Post PR comment
        if: always()
        uses: actions/github-script@v7
        env:
          SCORE: ${{ steps.scan.outputs.score }}
          CRITICAL: ${{ steps.scan.outputs.critical }}
          HIGH: ${{ steps.scan.outputs.high }}
        with:
          script: |
            const score = parseInt(process.env.SCORE || ''0'');
            const critical = parseInt(process.env.CRITICAL || ''0'');
            const high = parseInt(process.env.HIGH || ''0'');
            const threshold = 75;
            const passed = score >= threshold && critical === 0;

            const icon = passed ? ''✅'' : ''❌'';
            const badge = passed ? ''**APPROVED**'' : ''**BLOCKED**'';

            const body = [
              `### ${icon} ZeriFlow Security Gate — ${badge}`,
              '''',
              `| Metric | Value |`,
              `|--------|-------|`,
              `| Security Score | ${score}/100 |`,
              `| Threshold | ${threshold}/100 |`,
              `| Critical Findings | ${critical} |`,
              `| High Findings | ${high} |`,
              '''',
              passed
                ? ''This PR meets the security requirements and is approved to merge.''
                : `This PR is **blocked**. Resolve all critical findings and bring the score to at least ${threshold}/100 before merging.`,
              '''',
              ''_Powered by [ZeriFlow](https://zeriflow.com)_''
            ].join(''\\n'');

            // Find and update existing comment, or create a new one
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number
            });

            const existing = comments.find(c => c.body.includes(''ZeriFlow Security Gate''));

            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body
              });
            }

Step 2: Configure Branch Protection Rules

Once the workflow is in place and has run at least once on a PR, the check name appears in GitHub''s branch protection configuration.

Navigate to Settings > Branches > Add branch protection rule (or edit an existing rule for main).

Configure these settings:

Branch name pattern: main

[x] Require a pull request before merging
    [x] Require approvals: 1

[x] Require status checks to pass before merging
    [x] Require branches to be up to date before merging
    
    Search for and add: "Security Gate"   <- exact job name from your YAML

[x] Require conversation resolution before merging

[x] Do not allow bypassing the above settings

The "Do not allow bypassing" toggle is important. Without it, repository administrators can merge through a failing security check. Enabling it means the only bypass is temporarily removing the protection rule itself — which creates an audit trail and adds friction.


Step 3: Use Rulesets for Multi-Branch or Organization-Wide Rules

GitHub''s branch protection UI applies rules one branch pattern at a time and requires configuring each repository individually. Repository rulesets and organization rulesets are the modern replacement and support glob patterns across multiple branches and repositories.

Settings > Rules > Rulesets > New ruleset > New branch ruleset

Name: security-gate
Enforcement status: Active

Target branches:
  - Default branch
  - refs/heads/release/*
  - refs/heads/hotfix/*

Rules:
  [x] Require status checks to pass
      Required checks: Security Gate
  [x] Block force pushes
  [x] Require linear history

For organization-wide enforcement, navigate to your Organization Settings > Rules > Rulesets and apply the same configuration with a repository filter. This enforces the security gate across every repository in the organization without requiring per-repo configuration.


Configuring ZeriFlow''s Native CI/CD Integration

If you are on ZeriFlow Pro or higher, the platform has a native CI/CD integration that simplifies the workflow. Instead of parsing JSON yourself, you install the ZeriFlow GitHub App and configure thresholds in the dashboard.

The integration automatically: - Posts a status check named zeriflow/security-gate on every PR - Respects per-project score thresholds you set in the ZeriFlow dashboard - Includes a link to the full scan report in the check details - Handles API key rotation without touching your YAML

To use it, add zeriflow/security-gate as your required status check name instead of the job name from the manual workflow.

For teams that prefer not to install a GitHub App, the manual YAML approach above is equivalent and gives you more control over threshold logic and exception handling.


Handling Exceptions Without Undermining the Gate

A security gate that can never be bypassed will eventually be removed by an engineer facing a production incident. Build a legitimate, auditable exception mechanism instead.

Option 1: Labels on PRs

Add special handling for a security-exception label that requires explicit human approval:

yaml
      - name: Check for exception label
        id: exception
        run: |
          LABELS=''${{ toJson(github.event.pull_request.labels.*.name) }}''
          if echo "$LABELS" | jq -e ''.[] | select(. == "security-exception")'' > /dev/null; then
            echo "has_exception=true" >> "$GITHUB_OUTPUT"
          else
            echo "has_exception=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Enforce threshold (with exception check)
        run: |
          if [ "${{ steps.exception.outputs.has_exception }}" = "true" ]; then
            echo "Exception label present — skipping threshold enforcement"
            echo "IMPORTANT: This exception must be reviewed and resolved within 48 hours"
            exit 0
          fi
          # ... normal threshold enforcement

Configure a CODEOWNERS rule so the security-exception label can only be applied by members of a @security-reviewers team. This means an exception requires a security team member to explicitly approve it.

Option 2: Expiring exception files

For findings that cannot be fixed immediately, require a documented exception file in the repository:

json
// .security-exceptions.json
[
  {
    "check_id": "missing-csp",
    "reason": "CSP implementation in progress — blocked by ticket ENG-4421",
    "expires_at": "2026-06-15",
    "approved_by": "alice@example.com",
    "approved_at": "2026-05-01"
  }
]

Your CI script reads this file, verifies expiry dates, and deducts excepted findings from the failure criteria. Any exception with a past expiry date is treated as a hard failure.


What Threshold Should You Use?

There is no universal answer, but here is a reasonable progression:

Team maturityRecommended thresholdNotes
Just getting started60Baseline — catches only critical misconfigurations
Active security improvement75Blocks most dangerous issues
Security-conscious team85Catches medium severity issues
Compliance-driven (SOC 2, ISO 27001)90Near-exhaustive coverage

Start lower than you think you need. Running the gate at 60 for two weeks while you understand what findings your codebase has is more effective than setting it at 90 and immediately disabling it because every PR fails.

Schedule a quarterly review to raise the threshold by 5 points as your team resolves the backlog of findings.


Verifying the Gate Works

After configuration, verify the gate actually blocks merges:

  1. 1Create a test branch: git checkout -b test/security-gate-check
  2. 2Add a commit that intentionally degrades security (e.g., comment out a CSP header in your next.config.js)
  3. 3Open a PR to main
  4. 4Confirm the security-gate check fails and the merge button is disabled
  5. 5Fix the issue, push again, confirm the check passes and the merge button re-enables

Do this every time you update the workflow file. It takes five minutes and confirms the gate is actually enforcing your policy.


Conclusion

The goal is a world where an engineer cannot accidentally ship a misconfigured application because the CI pipeline treats security the same way it treats broken tests. Branch protection rules with required status checks are the mechanism. The ZeriFlow security scan is the signal.

Set up the workflow, configure the required status check, test it with a deliberate regression, and you are done. The gate runs on every PR from that point forward without any ongoing maintenance.

ZeriFlow''s Pro plan includes the native GitHub integration with configurable thresholds, plus 30 API calls per month for CI use.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading