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:
- 1A workflow YAML file defines a job with a specific name
- 2That job runs on the
pull_requestevent and exits 0 (pass) or non-zero (fail) - 3Branch protection rules list that job''s name under "Required status checks"
- 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.
# .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 settingsThe "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 historyFor 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:
- 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 enforcementConfigure 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:
// .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 maturity | Recommended threshold | Notes |
|---|---|---|
| Just getting started | 60 | Baseline — catches only critical misconfigurations |
| Active security improvement | 75 | Blocks most dangerous issues |
| Security-conscious team | 85 | Catches medium severity issues |
| Compliance-driven (SOC 2, ISO 27001) | 90 | Near-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:
- 1Create a test branch:
git checkout -b test/security-gate-check - 2Add a commit that intentionally degrades security (e.g., comment out a CSP header in your
next.config.js) - 3Open a PR to
main - 4Confirm the security-gate check fails and the merge button is disabled
- 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.