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
Refererheaders. 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 ""
Save it, chmod +x check-headers.sh, run it:
./check-headers.sh https://example.com
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
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
Output only failures (for CI)
./check-headers.sh https://example.com | grep "❌"
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
Add it to a pre-deploy hook
# In your deploy script:
./check-headers.sh "$DEPLOY_URL" || { echo "Header check failed"; exit 1; }
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;
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"
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();
});
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 ""
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-ageand warn if it's too low - Check CSP for
unsafe-inlineorunsafe-evaland 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)