Security built into the pipeline and the code. Find bugs before attackers do, and don't give CI more trust than it needs.
Last reviewed: 2026-10
← Supply Chain · Back to handbook · Next: Access & Infrastructure →
In this part
Store credentials as scoped repository, environment or organization secrets, never in workflow files:
Screenshot: GitHub Docs, CC BY 4.0.
CI/CD holds your source, secrets, cloud credentials and deploy rights. Compromise it and an attacker can ship malware in every release, with your signature on it.
| Incident | What happened |
|---|---|
| SolarWinds (2020) | Build system backdoored; about 18,000 customers received the trojanized update |
| Codecov (2021) | Tampered Bash uploader exfiltrated CI environment variables |
| PHP git server (2021) | Official PHP repo's git server compromised to push a backdoor |
| Travis CI (2022) | Secrets exposed in public build logs |
| CircleCI (2023) | Internal breach led to theft of customer secrets and keys |
| tj-actions/changed-files (2025) | Popular GitHub Action compromised to dump secrets from workflow logs |
- Pin third-party actions and tools to a full commit SHA, not a mutable tag (
uses: actions/checkout@<40-char-sha>). The tj-actions attack worked because tags were movable. - Use short-lived credentials via OIDC instead of stored cloud keys. GitHub Actions, GitLab and others can mint tokens per job.
- Least-privilege tokens. Set
permissions:explicitly in GitHub Actions (default tocontents: read). - Scope secrets to the environments and jobs that need them. Use protected branches and environments with required reviewers for production.
- Treat pull requests from forks as untrusted. Don't expose secrets to them. Be careful with
pull_request_targetand with interpolating PR titles or branch names into shell commands. - Use ephemeral runners. Rebuild on every job. Don't reuse self-hosted runners across trust levels.
- Separate pipelines (and runners) for dev, staging and production, with different privileges.
- Require signed commits and review before deploy jobs run.
- Mask secrets in logs and audit workflow files and the pipeline's own dependencies.
# GitHub Actions: minimal permissions and a pinned action
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1Functional isn't enough: code has to withstand hostile input. The OWASP Top 10 lists the most common web risks.
| Practice | What to do |
|---|---|
| Input validation | Treat all input as untrusted. Validate on the server against an allow-list. |
| Output encoding | Encode output for its context (HTML, JS, URL) to prevent XSS. |
| Parameterized queries | Never concatenate user input into SQL. Use prepared statements or an ORM. |
| Authentication and sessions | Strong auth plus MFA. Use Secure, HttpOnly and SameSite cookies. |
| Authorization | Check permissions on every request, server-side, for every object. |
| Error handling | Generic errors to users, detailed logs for operators. No stack traces in responses. |
| Least privilege | Run services with the minimum rights. Never as root. |
Example: SQL injection.
# ❌ Vulnerable: attacker input becomes SQL
cursor.execute("SELECT * FROM users WHERE username = '" + username + "'")
# ✅ Parameterized query
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))The top-ranked OWASP risk. The user is logged in, but are they allowed to access this object? Authentication answers "who are you"; authorization answers "may you do this".
# ❌ Any logged-in user can read any invoice by changing the ID
@app.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user=Depends(current_user)):
return db.query(Invoice).get(invoice_id)
# ✅ Scope the query to the caller (or check ownership explicitly)
@app.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user=Depends(current_user)):
invoice = db.query(Invoice).filter_by(id=invoice_id, owner_id=user.id).first()
if invoice is None:
raise HTTPException(404) # 404, not 403, so IDs can't be enumerated
return invoiceEnforce this centrally (policy layer, middleware, row-level security) rather than remembering it in each handler. Also check function-level access: admin endpoints must verify the role, not just hide the button.
If your server fetches a URL supplied by a user (webhooks, image import, link previews), an attacker can point it at internal services or the cloud metadata endpoint (http://169.254.169.254/) and steal credentials.
- Allow-list destination hosts where possible.
- Resolve the hostname, then reject private, loopback and link-local addresses, and re-check after redirects (or disable redirects).
- Require IMDSv2 on AWS, and give workloads minimal roles so a stolen token is worth little.
- Run outbound fetchers in a network segment with restricted egress.
| Risk | Don't | Do |
|---|---|---|
| OS command injection | os.system(f"convert {filename}") |
Pass an argument list: subprocess.run(["convert", filename], check=True). Never use shell=True with user input. |
| Path traversal | open("/uploads/" + name) with ../../etc/passwd |
Resolve the path and verify it stays inside the base directory; generate server-side filenames. |
| Unsafe deserialization | pickle.loads(data), yaml.load() on untrusted input |
Use JSON, or yaml.safe_load. Never unpickle data you don't control. |
| Template injection | Building templates from user input | Pass user data as template variables only. |
| XSS | Insert raw user input with innerHTML |
Use framework escaping; sanitize any HTML with a vetted library; set a Content-Security-Policy. |
- Passwords: hash with Argon2id, scrypt or bcrypt. Never SHA-256 alone, never reversible encryption. Verify with the library's constant-time compare.
- Rate-limit and lock out login, password reset and MFA endpoints. Return the same message whether or not the account exists.
- Sessions: rotate the session ID on login, expire idle sessions, invalidate on logout and password change.
- JWTs: pin the expected algorithm (reject
none), validateexp,audandiss, keep lifetimes short, and don't store secrets in the payload (it's readable). - Password reset and invite links: random, single-use, short-lived tokens, stored hashed.
- Use well-reviewed libraries (libsodium, your platform's crypto API). Don't invent algorithms or modes.
- Use the OS CSPRNG (
secretsin Python,crypto.randomBytesin Node), notMath.random()orrandom, for tokens and keys. - Use TLS 1.2+ everywhere, including internal service-to-service traffic. Never disable certificate verification to "make it work".
- Keep keys out of source. See secrets in
.envfiles.
Content-Security-Policy: default-src 'self'
Strict-Transport-Security: max-age=63072000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Also set cookies with Secure; HttpOnly; SameSite=Lax (or Strict), and protect state-changing requests against CSRF.
- Rate-limit and size-limit requests; cap pagination and query complexity (especially GraphQL).
- Validate the schema of every request body and reject unknown fields (mass assignment).
- Don't return more fields than the client needs (excessive data exposure).
- Verify webhook signatures and check timestamps to prevent replay.
- Log security-relevant events (auth failures, permission denials) without logging secrets or personal data.
- OWASP Cheat Sheet Series: short, practical guides per topic
- OWASP API Security Top 10
- OWASP ASVS: a checklist for what "secure" means
"Shift left" means finding problems at commit and pull-request time, when they are cheap to fix. Automate each of these in CI and block merges on serious findings.
| Type | Finds | Tools |
|---|---|---|
| SAST (static analysis) | Insecure code patterns in source | Semgrep, SonarQube, CodeQL, Bandit (Python), ESLint security plugins |
| DAST (dynamic analysis) | Vulnerabilities in a running app | OWASP ZAP, Burp Suite |
| Dependency scanning | Known-vulnerable libraries | Dependabot, Renovate, Snyk, npm audit, pip-audit, OSV-Scanner |
| Secret scanning | Keys and tokens in code and history | Gitleaks, TruffleHog, GitHub push protection |
| IaC scanning | Cloud misconfiguration | Checkov, tfsec / Trivy |
| Container scanning | OS and library CVEs in images | Trivy, Grype |
GitHub's built-in push protection blocks a push that contains a detected secret. You can enable it for your own account, and organizations can enable it for every repository:
Screenshot: GitHub Docs, CC BY 4.0.
Dependabot alerts show the vulnerable dependency and can open a fix pull request in one click:
Screenshot: GitHub Docs, CC BY 4.0.
Quick start for secret scanning as a pre-commit hook:
brew install gitleaks
gitleaks protect --staged # run before each commit (or wire it into pre-commit)If a scanner finds a real secret, rotate it first, then clean up Git history.
- OWASP Web Security Testing Guide
- NIST Secure Software Development Framework (SP 800-218)
- GitHub code security docs
- Microsoft Security Development Lifecycle
- OWASP ZAP documentation
← Supply Chain · Back to handbook · Next: Access & Infrastructure →


