DEV Community

Kai Learner
Kai Learner

Posted on • Originally published at dev.to

Build a Security Header Scanner in 50 Lines of Bash

Build a Security Header Scanner in 50 Lines of Bash

You don't need Node.js, Python, or a framework to audit a website's security headers. All you need is curl and bash — tools already on every Unix system.

Here's how to build a real, useful security header scanner in about 50 lines.


Why Security Headers Matter

Before we write a single line, the quick version:

  • Content-Security-Policy (CSP) — tells the browser which scripts/styles are allowed. Missing = XSS amplification.
  • Strict-Transport-Security (HSTS) — forces HTTPS. Missing = MITM risk.
  • X-Frame-Options — blocks clickjacking. Missing = your login page can be embedded in an iframe.
  • X-Content-Type-Options — stops MIME sniffing. Missing = content injection risk.
  • Referrer-Policy — controls what gets sent in Referer headers. Missing = data leakage.
  • Permissions-Policy — controls browser features (camera, mic, GPS). Missing = fingerprinting risk.

Security scanners check for these. Let's build one.


The Script

#!/usr/bin/env bash
# check-headers.sh — Security header scanner
# Usage: ./check-headers.sh https://example.com

set -euo pipefail

URL="${1:-}"
if [[ -z "$URL" ]]; then
  echo "Usage: $0 <url>"
  exit 1
fi

# Fetch headers only (-I), follow redirects (-L), silent (-s)
HEADERS=$(curl -sIL --max-time 10 \
  -H "User-Agent: Mozilla/5.0 (compatible; HeaderScanner/1.0)" \
  "$URL" 2>/dev/null | tr '[:upper:]' '[:lower:]')

SCORE=0
MAX=6
PASS=0
FAIL=0

check() {
  local name="$1"
  local pattern="$2"
  local advice="$3"

  if echo "$HEADERS" | grep -q "$pattern"; then
    echo "  ✅  $name"
    SCORE=$((SCORE + 1))
    PASS=$((PASS + 1))
  else
    echo "  ❌  $name$advice"
    FAIL=$((FAIL + 1))
  fi
}

echo ""
echo "🔍 Scanning: $URL"
echo "─────────────────────────────────────────────"

check "Content-Security-Policy" \
  "^content-security-policy:" \
  "Add CSP to restrict script/style sources"

check "Strict-Transport-Security" \
  "^strict-transport-security:" \
  "Add HSTS with max-age >= 31536000"

check "X-Frame-Options" \
  "^x-frame-options:" \
  "Add X-Frame-Options: DENY to block clickjacking"

check "X-Content-Type-Options" \
  "^x-content-type-options:" \
  "Add X-Content-Type-Options: nosniff"

check "Referrer-Policy" \
  "^referrer-policy:" \
  "Add Referrer-Policy: strict-origin-when-cross-origin"

check "Permissions-Policy" \
  "^permissions-policy:" \
  "Add Permissions-Policy to restrict browser features"

echo "─────────────────────────────────────────────"

PCT=$(( (SCORE * 100) / MAX ))

if   [[ $PCT -ge 90 ]]; then GRADE="A+"
elif [[ $PCT -ge 80 ]]; then GRADE="A"
elif [[ $PCT -ge 70 ]]; then GRADE="B"
elif [[ $PCT -ge 60 ]]; then GRADE="C"
elif [[ $PCT -ge 40 ]]; then GRADE="D"
else                         GRADE="F"
fi

echo "  Score: $SCORE/$MAX ($PCT%) — Grade: $GRADE"
echo "  Passed: $PASS   Failed: $FAIL"
echo ""
Enter fullscreen mode Exit fullscreen mode

Save it, chmod +x check-headers.sh, run it:

./check-headers.sh https://example.com
Enter fullscreen mode Exit fullscreen mode

Output:

🔍 Scanning: https://example.com
─────────────────────────────────────────────
  ✅  Content-Security-Policy
  ✅  Strict-Transport-Security
  ❌  X-Frame-Options — Add X-Frame-Options: DENY to block clickjacking
  ✅  X-Content-Type-Options
  ❌  Referrer-Policy — Add Referrer-Policy: strict-origin-when-cross-origin
  ❌  Permissions-Policy — Add Permissions-Policy to restrict browser features
─────────────────────────────────────────────
  Score: 3/6 (50%) — Grade: D
  Passed: 3   Failed: 3
Enter fullscreen mode Exit fullscreen mode

Clean. Readable. Actionable.


How It Works

Line by line:

curl -sIL-s silent (no progress bar), -I HEAD request (headers only), -L follow redirects. We follow redirects because most sites redirect http://https:// and we want the final headers.

tr '[:upper:]' '[:lower:]' — normalize everything to lowercase. HTTP headers are case-insensitive but servers send them inconsistently. This makes our grep patterns reliable.

grep -q — quiet grep, just checks for presence, no output. Returns exit code 0 (found) or 1 (not found), which drives the if/else in check().

The check() function — takes a name, a grep pattern, and advice text. One function call per header. Easy to extend.

The grading — arithmetic in bash using $(( )). Simple percentage → letter grade mapping.


Extending It

Scan multiple URLs from a file

while IFS= read -r url; do
  ./check-headers.sh "$url"
done < urls.txt
Enter fullscreen mode Exit fullscreen mode

Output only failures (for CI)

./check-headers.sh https://example.com | grep "❌"
Enter fullscreen mode Exit fullscreen mode

Fail CI if grade is F

OUTPUT=$(./check-headers.sh "$URL")
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q "Grade: F"; then
  echo "Security check failed — Grade F"
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Add it to a pre-deploy hook

# In your deploy script:
./check-headers.sh "$DEPLOY_URL" || { echo "Header check failed"; exit 1; }
Enter fullscreen mode Exit fullscreen mode

What to Do With the Failures

If your scanner flags missing headers, here's the fix per server:

nginx:

add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
Enter fullscreen mode Exit fullscreen mode

Apache:

Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Enter fullscreen mode Exit fullscreen mode

Express (Node.js):

app.use((req, res, next) => {
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  next();
});
Enter fullscreen mode Exit fullscreen mode

Or just use helmet — it handles all of this automatically.


Why Bash Over a "Real" Tool?

Because sometimes you don't want to install anything.

curl and bash are on every Linux server, CI runner, Docker image, macOS machine. This script runs anywhere, instantly, with zero setup. That's the value.

For deeper analysis — checking header values, not just presence — a Node.js or Python tool makes more sense. I built one of those too: check out headers-check on GitHub if you want value validation and a proper JSON report.

But for a quick "is this site even trying?" gut-check, 50 lines of bash is all you need.


The Full Script (Copy-Paste Ready)

#!/usr/bin/env bash
set -euo pipefail
URL="${1:-}"; [[ -z "$URL" ]] && { echo "Usage: $0 <url>"; exit 1; }
HEADERS=$(curl -sIL --max-time 10 -H "User-Agent: Mozilla/5.0" "$URL" 2>/dev/null | tr '[:upper:]' '[:lower:]')
SCORE=0; MAX=6; PASS=0; FAIL=0
check() {
  if echo "$HEADERS" | grep -q "$2"; then echo "  ✅  $1"; SCORE=$((SCORE+1)); PASS=$((PASS+1))
  else echo "  ❌  $1$3"; FAIL=$((FAIL+1)); fi
}
echo ""; echo "🔍 Scanning: $URL"; echo "─────────────────────────────────────────────"
check "Content-Security-Policy"  "^content-security-policy:"  "Restrict script/style sources"
check "Strict-Transport-Security" "^strict-transport-security:" "max-age >= 31536000"
check "X-Frame-Options"          "^x-frame-options:"           "DENY to block clickjacking"
check "X-Content-Type-Options"   "^x-content-type-options:"    "nosniff"
check "Referrer-Policy"          "^referrer-policy:"           "strict-origin-when-cross-origin"
check "Permissions-Policy"       "^permissions-policy:"        "Restrict browser features"
echo "─────────────────────────────────────────────"
PCT=$(( (SCORE*100)/MAX ))
[[ $PCT -ge 90 ]] && G="A+" || [[ $PCT -ge 80 ]] && G="A" || [[ $PCT -ge 70 ]] && G="B" || [[ $PCT -ge 60 ]] && G="C" || [[ $PCT -ge 40 ]] && G="D" || G="F"
echo "  Score: $SCORE/$MAX ($PCT%) — Grade: $G | Passed: $PASS  Failed: $FAIL"; echo ""
Enter fullscreen mode Exit fullscreen mode

What's Next

The script as written checks for presence. A header that exists but is misconfigured (like Content-Security-Policy: *) still passes. For value validation, you need to go deeper.

Ideas to extend this further:

  • Parse HSTS max-age and warn if it's too low
  • Check CSP for unsafe-inline or unsafe-eval and flag them
  • Compare before/after headers across deploys
  • Generate a Markdown report and commit it to the repo

Drop a comment if you build any of these — I'd genuinely like to see what you add.


AI disclosure: Written with AI assistance. All code tested and verified.

Top comments (0)