How to Add a GitHub Actions Security Scan to Your CI/CD Pipeline
The short answer: add a job to your GitHub Actions workflow that runs after your deploy step, have it call the SteelSuit scan API against your staging or preview URL, wait for the result, and exit non-zero if there are high or critical findings. That turns a security check into an automatic gate that runs on every deploy — no scanner to install, just curl and an API key.
This guide gives you a complete, copy-pasteable workflow for a GitHub Actions security scan, explains each step, and shows how to turn it into a security check before deploy that can fail the build.
Why run a security scan in CI/CD at all?
Manual security reviews don't keep up with continuous delivery. If you ship multiple times a day, the only way to catch regressions — a header that got dropped, a debug endpoint that got exposed, a TLS misconfiguration on a new subdomain — is to make the check part of the pipeline. That is the whole idea behind shifting security left, and the OWASP DevSecOps Guideline describes building exactly these automated controls into delivery.
SteelSuit is an external, black-box scanner: it takes a deployed URL, runs a chain of recon and vulnerability checks against it from the outside, and returns deduped findings with a severity, an A–F (and 0–100) score, and a signed report link. There's no agent to install and no source-code access — which is precisely what makes it easy to drop into automated security testing in CI. You point it at the URL you just deployed.
What you need before you start
| Requirement | Detail |
|---|---|
| A SteelSuit API key | Format sk_..., created in Dashboard → API keys. API access is a paid-plan feature; the free plan has no API. |
| The key stored as a secret | Add it as a repository or environment encrypted secret named STEELSUIT_API_KEY. Never hard-code it in the workflow. |
| A staging/preview URL | The deploy step should expose the URL you want to scan (a Vercel/Netlify preview, a staging host, etc.). |
| A delivery channel | The API requires at least one delivery channel (webhook, email, or slack). The simplest is email to your own account address. |
The flow: start, poll, gate
The API is intentionally small. Three calls cover the whole job:
- Start the scan —
POST /api/v1/integrations/scanswith the target URL, apipeline, and the requireddeliveryarray. It returns202with ascan_id, a signedresult_url(valid 7 days), and the approximate queue depth. - Poll for completion —
GET /api/v1/scans/{scan_id}returns astatus. Wait until it reaches a terminal state:completed,failed, orpartial. - Gate on findings —
GET /api/v1/scans/{scan_id}/findings?severity=critical,highreturns only the severities you ask for. If the list is non-empty, fail the build.
Auth is the same on every call: an Authorization: Bearer sk_... header.
The workflow YAML
Drop this into .github/workflows/security-scan.yml. It runs after a deploy job (referenced here as deploy) and reads the preview URL from that job's output.
name: Security scan
on:
push:
branches: [main]
jobs:
# ... your existing deploy job that publishes a preview/staging URL ...
# It must expose the URL as an output named `preview_url`.
security-scan:
needs: deploy
runs-on: ubuntu-latest
env:
STEELSUIT_API_KEY: ${{ secrets.STEELSUIT_API_KEY }}
TARGET_URL: ${{ needs.deploy.outputs.preview_url }}
steps:
- name: Start scan, poll, and gate on high/critical findings
run: |
set -euo pipefail
# 1. Start the scan against the deployed preview/staging URL.
# `delivery` is REQUIRED — email to your own account is simplest.
# `pipeline` is fast_scan (~26s) or deep_scan (~5-6 min).
scan_id=$(curl -fsS -X POST \
https://steelsuit.com/api/v1/integrations/scans \
-H "Authorization: Bearer $STEELSUIT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"target\":\"$TARGET_URL\",\"pipeline\":\"fast_scan\",\"delivery\":[{\"type\":\"email\"}]}" \
| jq -r .scan_id)
echo "Started scan $scan_id"
# 2. Poll until the scan reaches a terminal state.
for _ in $(seq 1 60); do
status=$(curl -fsS \
-H "Authorization: Bearer $STEELSUIT_API_KEY" \
"https://steelsuit.com/api/v1/scans/$scan_id" | jq -r .status)
echo "status: $status"
case "$status" in
completed|failed|partial) break ;;
esac
sleep 5
done
# 3. Fail the build on any high or critical finding.
n=$(curl -fsS \
-H "Authorization: Bearer $STEELSUIT_API_KEY" \
"https://steelsuit.com/api/v1/scans/$scan_id/findings?severity=critical,high" \
| jq 'length')
echo "high/critical findings: $n"
if [ "$n" -ne 0 ]; then
echo "::error::SteelSuit found $n high/critical issue(s)"
exit 1
fi
A few notes on why it's written this way:
set -euo pipefailmakes the step fail loudly if any curl fails, rather than silently continuing.curl -fsSfails on HTTP errors (-f), stays quiet on progress (-s), but still prints errors (-S).- The poll loop is bounded (60 iterations × 5s). A
fast_scanis usually done in under a minute; the cap stops a hung job from running forever. - The gate uses
?severity=critical,high. Change it to justcriticalif you only want to block on the most serious issues, or addmediumto be stricter.
For a runnable variant and notes on other runners, see the internal CI integration docs.
Webhook instead of polling
If your CI can receive an HTTP callback — or you'd rather not hold a runner open while the scan runs — swap the email delivery for a webhook and drop the poll loop entirely. SteelSuit POSTs a result envelope (score, finding counts, and the signed result_url) to your endpoint when the scan finishes, signed with your shared secret in the X-SteelSuit-Signature header so you can verify it:
{ "type": "webhook", "target": "https://example.com/hooks/steelsuit", "secret": "your-shared-secret" }
You can list up to five delivery channels in one request, so it's fine to send a webhook and an email at the same time.
Which pipeline should CI use?
fast_scan(~26 seconds) is the default for CI. It's quick enough to run inline on every push without slowing the pipeline noticeably.deep_scan(~5–6 minutes) is more thorough but requires DNS-TXT domain-ownership verification for the target, so it's best for scheduled runs or release branches rather than every pull request.pentest(active scanning) is not available over the API — it's deliberately gated, so don't reference it in a workflow.
A practical setup: run fast_scan on every push to a feature branch, and reserve deep_scan for merges into main or a nightly schedule. Each scan consumes your plan's per-period quota, so if you're scanning every pull request, scope the job to the branches that matter.
Reading the result
When the gate fails, the same result_url from the start response opens the full report so a human can triage. The findings endpoint also returns JSON your tooling can parse, and a ?format=llm variant produces a fix-oriented prompt if you want to feed it to an assistant. To understand the severity levels and score, see how to read a vulnerability scan report.
If you're standing up CI security from scratch, pair this with a baseline review using the web app security checklist, and — if you're shipping a lot of AI-generated code — the patterns in keeping vibe-coded apps secure are worth a read before you trust the gate.
Common pitfalls
- Scan the preview, not production. Always point the job at the staging/preview deploy, not your live site.
- One scan per domain at a time. Starting a second scan for a domain that already has one queued or running returns
409. Let the previous one finish, or scope CI tomain. - Keep the key out of logs. GitHub redacts registered secrets in logs automatically, but don't
echothe key yourself. Store it as an encrypted secret and reference it only via${{ secrets.STEELSUIT_API_KEY }}. - Make the job a required check. A failing job only blocks a merge if it's marked required in your branch protection rules — see the GitHub Actions documentation.
That's the whole pattern: deploy to a preview, scan it, and let the result decide whether the build moves forward. A few lines of YAML turn an external security scan into a standing CI/CD security scan that runs on every change.
Frequently asked
How do I run a security scan in GitHub Actions?
Add a workflow step that calls a scanning API with curl. With SteelSuit, store your sk_ API key as an encrypted secret, then POST to /api/v1/integrations/scans with your staging URL, a pipeline (fast_scan or deep_scan), and a required delivery channel. The response gives you a scan_id you poll for the result. No tool needs to be installed on the runner — it's plain HTTP, so any runner with curl and jq works.
Can I fail a CI build on security findings?
Yes. After the scan reaches a terminal state, fetch the findings filtered to the severities you care about — for example GET /api/v1/scans/{id}/findings?severity=critical,high — and exit the step with a non-zero code if any are returned. GitHub Actions marks the job as failed, which blocks the merge or deploy if the job is a required check.
Should I scan production or staging in CI?
Scan your staging or preview deployment, not production. In a CI/CD pipeline you typically deploy to a preview environment first, so point the scan at that URL. SteelSuit's fast_scan and deep_scan are passive external recon, but scanning the preview keeps load and noise off production while still testing the exact build you are about to ship.
Do I need to install anything on my GitHub Actions runner?
No. The scan runs server-side via the SteelSuit API. Your workflow only needs curl and jq, which are already present on GitHub-hosted runners, plus an API key stored as an encrypted secret. There is no agent, no scanner binary, and no source-code access involved — it is a black-box external scan of your deployed URL.
How long does a scan take in a CI pipeline?
A fast_scan typically finishes in around 26 seconds, which is short enough to run inline on every deploy. A deep_scan takes roughly five to six minutes and requires DNS-TXT domain-ownership verification, so it suits scheduled or release-branch runs rather than every pull request.