Fail a GitHub Actions Build on Missing Headers

This guide is part of the Automated Security Header Scanning in CI/CD reference. It builds the concrete gate that reference describes: a GitHub Actions job that boots or reaches a deployed URL, asserts every required security header with curl -sI and grep, and exits non-zero the instant one is missing. A green check must mean the headers shipped. This page makes a missing header a red X on the pull request, not a discovery in production.

Configuration Syntax & Exact Values

The gate rests on one fact: a step fails when its final command returns a non-zero exit code. GitHub Actions runs each run: block under bash -e semantics only when you opt in, so the exit code of your check is the build verdict. The check has three moving parts — fetch the headers once, test for each required header, and return the aggregate result.

curl -sI issues a HEAD request and prints response headers only. -s silences the progress meter, -I requests headers, and adding -S keeps error messages while staying quiet. grep -qi tests for a pattern case-insensitively (Strict-Transport-Security and strict-transport-security are the same header) and -q suppresses output so only the exit code matters — 0 on a match, 1 on a miss.

# Fetch once, reuse the captured headers for every assertion.
headers="$(curl -sSI https://staging.example.com/)"

# grep -q returns 0 (found) or 1 (missing); -i makes the match case-insensitive.
echo "$headers" | grep -qi '^strict-transport-security:'
echo "$headers" | grep -qi '^content-security-policy:'
echo "$headers" | grep -qi '^x-content-type-options:'

The header names are matched with a leading ^ and trailing : so a value that merely contains the word cannot produce a false pass. The exact header strings you assert are the canonical ones: Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy and, where framing matters, X-Frame-Options. The annotated breakdown below shows how a single captured response is dissected into pass/fail signals.

Annotated header assertion A captured HTTP response header block on the left with arrows pointing to the grep assertions and their exit codes on the right. Captured response (curl -sSI) HTTP/2 200 strict-transport-security: max-age=63072000 content-security-policy: default-src 'self' x-content-type-options: nosniff referrer-policy: (absent) content-type: text/html grep '^strict-transport' -> exit 0 grep '^content-security' -> exit 0 grep '^x-content-type' -> exit 0 grep '^referrer-policy' -> exit 1 One exit 1 fails the job
A single captured header block feeds every grep; the one miss (Referrer-Policy) returns exit 1 and sinks the build.

Server-Side Configuration

“Server-side” here means the workflow file and the reusable check script that live in your repository — they are the server-side of the pipeline that decides whether the deployed origin passes. Two shapes are given: a self-contained inline check, and a reusable script you commit once and call from any job.

GitHub Actions: inline curl-and-grep gate

This workflow reaches an already-deployed preview or staging URL and asserts each header. The loop collects every miss before failing, so one run reports all gaps rather than only the first.

name: security-headers
on:
  pull_request:
  workflow_dispatch:

jobs:
  header-gate:
    runs-on: ubuntu-latest
    steps:
      - name: Assert required security headers
        env:
          TARGET_URL: https://staging.example.com/
        run: |
          set -euo pipefail
          required=(
            "strict-transport-security"
            "content-security-policy"
            "x-content-type-options"
            "referrer-policy"
            "x-frame-options"
          )
          headers="$(curl -sSI --fail-with-body --max-time 15 "$TARGET_URL")"
          missing=0
          for h in "${required[@]}"; do
            if echo "$headers" | grep -qi "^${h}:"; then
              echo "ok   $h"
            else
              echo "MISS $h"
              missing=1
            fi
          done
          if [ "$missing" -ne 0 ]; then
            echo "::error::One or more required security headers are missing"
            exit 1
          fi

set -euo pipefail is load-bearing. Without -e, a failing command mid-script is ignored and the step still exits 0. Without -o pipefail, a failure inside a pipe (curl ... | grep) is masked by the exit code of the last stage. The ::error:: workflow command surfaces the failure as an annotation on the pull request. --fail-with-body makes curl itself return non-zero on a 5xx so an origin that is down fails the gate rather than silently returning an empty header set.

Reusable bash check script

Commit this as scripts/check-headers.sh, mark it executable (chmod +x), and call it from any job. It reads the URL as an argument and the required list from the environment, which keeps the workflow YAML thin.

#!/usr/bin/env bash
# scripts/check-headers.sh — exit 0 if all required headers present, 1 otherwise.
set -euo pipefail

url="${1:?usage: check-headers.sh <url>}"
# Space-separated, overridable at call time.
required="${REQUIRED_HEADERS:-strict-transport-security content-security-policy x-content-type-options referrer-policy}"

headers="$(curl -sSI --fail-with-body --max-time 15 -L "$url")"
status=0
for h in $required; do
  if printf '%s\n' "$headers" | grep -qi "^${h}:"; then
    printf 'ok   %s\n' "$h"
  else
    printf 'MISS %s\n' "$h"
    status=1
  fi
done
exit "$status"
      - name: Header gate via script
        env:
          REQUIRED_HEADERS: "strict-transport-security content-security-policy x-content-type-options"
        run: ./scripts/check-headers.sh https://staging.example.com/

The -L flag follows redirects so the script asserts headers on the final 200, not on an intermediate 301. This matters because headers must be present on the landing response; a redirect that strips them leaves a gap. The auditing headers with curl, openssl and testssl.sh reference explains why each redirect hop is a distinct response worth probing.

Booting an ephemeral server in the job

When there is no preview URL, boot the app inside the runner, wait for it to answer, then assert against localhost. Backgrounding the server with & frees the step to continue; the polling loop prevents a race where curl fires before the port is open.

      - name: Boot app and gate headers
        run: |
          set -euo pipefail
          npm ci
          npm start &          # background the server
          for i in $(seq 1 30); do
            curl -sf http://localhost:8080/ >/dev/null && break
            sleep 1
          done
          ./scripts/check-headers.sh http://localhost:8080/

Any origin works the same way — a container, a Django or FastAPI dev server, or a static preview served by Nginx. The gate only cares about the bytes on the wire.

The sequence below shows the full pipeline from a pushed commit to the pass/fail verdict on the pull request.

CI header-gate sequence A vertical sequence from a git push through the runner booting the app, curl fetching headers, grep asserting, and the exit code updating the pull request status. Developer Runner Origin Pull request push / open PR boot app + wait curl -sSI header block grep each required exit 0 / 1 status: pass or fail
The runner boots the origin, curl fetches the headers, grep asserts each one, and the aggregate exit code sets the pull request status.

Diagnostic & Verification Steps

Before trusting the gate in CI, run the check by hand and confirm it fails when it should. A gate that never fails is worse than no gate — it manufactures confidence.

Fetch the live headers and read them raw:

curl -sSI https://staging.example.com/
# HTTP/2 200
# strict-transport-security: max-age=63072000; includeSubDomains; preload
# content-security-policy: default-src 'self'
# x-content-type-options: nosniff
# referrer-policy: strict-origin-when-cross-origin
# ...

Run the reusable script against the same URL and inspect the exit code — $? holds the last command’s status:

./scripts/check-headers.sh https://staging.example.com/
# ok   strict-transport-security
# ok   content-security-policy
# ok   x-content-type-options
# ok   referrer-policy
echo $?
# 0

Now prove the failure path. Point the script at an origin you know is missing a header, or force one into the required list that the site does not send:

REQUIRED_HEADERS="content-security-policy x-frame-options permissions-policy" \
  ./scripts/check-headers.sh https://example.com/
# ok   content-security-policy
# MISS x-frame-options
# MISS permissions-policy
echo $?
# 1

Exit 1 here is the correct, desired result: it confirms a real miss turns red. Verify the pipefail guard too — a broken pipe must not read as success:

# Simulate an unreachable origin: curl fails, and with pipefail the step fails.
set -o pipefail
curl -sSf --max-time 5 https://does-not-resolve.example/ | grep -qi 'content-security-policy'
echo $?
# non-zero — the curl failure propagates instead of being masked by grep

Reproduce the exact runner environment locally with act or by running the script under the same shell flags GitHub uses. Within a running Action, add a debug step that echoes the captured headers on failure so a red build is diagnosable without re-running:

      - name: Dump headers on failure
        if: failure()
        run: curl -sSI https://staging.example.com/ || true

Edge Cases, Security Implications & Safe Rollback

Three traps sink header gates in practice.

The gate hits the wrong response shape. A scanner and your gate often fetch GET / and stop there. But security headers are emitted by server logic that branches on path, method and status code. Without the always flag on Nginx or Header always set on Apache, headers are absent on 4xx and 5xx — exactly the responses an attacker induces. Assert against an error path too, so a bare 404 fails CI:

# A 404 must still carry the headers; assert it explicitly.
./scripts/check-headers.sh https://staging.example.com/this-path-404s

Silent success on a downed origin. If curl returns an empty body because the origin 502s and you did not pass --fail-with-body (or -f), every grep fails to match and — depending on how you wrote the loop — the step may still exit 0 if the curl failure was swallowed. Always let the transport failure propagate: curl -sSf plus set -o pipefail guarantees an unreachable origin fails the gate rather than passing it vacuously.

Value presence versus value correctness. grep '^strict-transport-security:' proves the header exists, not that max-age is sane. A gate that accepts max-age=0 passes a header that actively disables HSTS. When correctness matters, assert the value, not just the name:

echo "$headers" | grep -Eiq '^strict-transport-security:.*max-age=[1-9][0-9]{6,}'
# requires max-age of at least ~4 months (7-digit seconds)

Rollback is deliberate. A newly-strict gate can wall off every pull request on day one if the origin is not yet compliant. Introduce the job as non-blocking first with continue-on-error: true, watch it report misses for a cycle, fix the origin, then remove the flag to make it enforcing:

  header-gate:
    runs-on: ubuntu-latest
    continue-on-error: true   # report-only; delete this line to enforce
Gate rollout states A state machine moving from report-only through fixing the origin to an enforcing gate, with a rollback edge back to report-only. Report-only continue-on-error Fix origin add missing headers Enforcing flag removed misses seen clean run rollback: re-add continue-on-error on regression
Roll the gate out report-only, fix the origin, then enforce; the dashed edge is the rollback if a regression floods every pull request.

Conclusion

Land the gate against staging in report-only mode with continue-on-error: true and a short list of the headers that already ship. Watch it annotate misses for a cycle while you harden the origin — start with a short max-age and report-only CSP so a mistake never breaks the site. Once staging runs clean, remove the flag to make the job enforcing, then promote the same check into the production deploy path. A green build then means, provably, that every required header reached the wire.

Frequently Asked Questions

Why does my run: step pass even though grep found nothing? By default Bash does not stop on the first error, and a failure inside a pipe is hidden by the last command’s exit code. Add set -euo pipefail at the top of the block, and use an explicit missing=1; exit 1 accumulator so a miss deterministically returns non-zero.

Should the gate hit a live URL or boot the app in the runner? Both are valid. Hit a deployed preview or staging URL when your platform generates one per pull request; boot an ephemeral server with npm start & plus a readiness loop when there is no preview. Either way you assert against the real HTTP response, which is the only thing that counts.

How do I check a header’s value, not just its presence? Extend the pattern with grep -Eiq. For example grep -Eiq '^strict-transport-security:.*max-age=[1-9][0-9]{6,}' requires a seven-digit max-age, which rejects max-age=0 and other values that would disable the protection while still technically sending the header.

Will the gate miss headers that are absent on error pages? Yes, if it only fetches GET /. Servers commonly drop security headers on 4xx and 5xx responses unless the always (Nginx) or Header always set (Apache) flag is used. Add an assertion against a known 404 path so a bare error page fails the build.

How do I stop a strict new gate from blocking every pull request? Introduce the job with continue-on-error: true so it reports misses without failing the build. Fix the origin over one cycle, confirm a clean run, then delete that line to switch the gate to enforcing.