Mojo Layers

How to Secure Your GitHub Organisation: A Hardening Checklist for Small Teams

A practical GitHub security checklist for small teams covering 2FA, token policies, force-push rulesets, secret scanning and locking down GitHub Actions.

By Joe Shenton7 min readSecurityGitHub

Most small teams treat GitHub as a place to store code. Attackers treat it as a place to store keys: personal access tokens, cloud credentials in Actions secrets, deploy keys, and the ability to push code that runs on other people's machines.

The big supply-chain incidents of the last couple of years have mostly been about exactly that. The Shai-Hulud npm worm compromised more than 500 packages in September 2025, harvested GitHub tokens and cloud keys from developers' machines, and published them to public GitHub repositories. Six months earlier, the popular tj-actions/changed-files Action was tampered with so that its version tags pointed at code that dumped CI secrets into workflow logs (CVE-2025-30066).

We've been on the receiving end too. In our own incident write-up, a collaborator's stolen credentials were used to force-push backdoored commits into six repositories across two GitHub accounts in about two minutes. Nothing about that access was "hacked" from GitHub's point of view; it was a legitimate account doing things it was allowed to do.

So this checklist is about shrinking what a stolen account or token is allowed to do, and making sure you notice quickly when one is used. Everything here works on GitHub's Free or Team plans unless noted.

1. Require two-factor authentication for the whole organisation

This is the single cheapest control. In your organisation's Settings → Authentication security, turn on Require two-factor authentication.

Per GitHub's documentation, members who don't have 2FA lose access to org resources until they turn it on, and outside collaborators without 2FA are removed (you can reinstate them within three months once they enable it). So give people a day's notice before you flip the switch.

Where you can, push people towards passkeys or hardware security keys rather than SMS. CISA's Shai-Hulud guidance specifically calls for phishing-resistant MFA on developer accounts, GitHub and npm included.

2. Put a policy on personal access tokens

Personal access tokens (PATs) are what infostealers go looking for, because they work without a second factor. GitHub lets organisation owners set a token policy under Settings → Personal access tokens:

Setting What we'd choose for a small team Why
Classic tokens Restrict access to the org Classic tokens are broad and often never expire
Fine-grained tokens Allow, but require administrator approval You see every token that touches the org, and what it can reach
Maximum lifetime 90 days or less The default cap for fine-grained tokens is 366 days

Then do the human part: ask everyone to review Settings → Developer settings on their own account and delete tokens they don't recognise or no longer use. A token created for a weekend experiment three years ago is still a key.

3. Block force pushes and deletions with rulesets

Force pushes are how an attacker quietly rewrites history so a malicious change looks like it was always there. Deleting a branch or tag is how they cover tracks or swap a release.

Repository rulesets fix both. They're available on public repositories on every plan, and on private repositories on GitHub Pro, Team and Enterprise. For your default branch (and any release branches), create a ruleset under Settings → Rules → Rulesets with at least:

  • Block force pushes
  • Restrict deletions
  • Require a pull request before merging, with at least one approval if you have more than one developer
  • Require status checks to pass before merging, so CI has to run

Add a second ruleset that targets tags (for example v*) with Block force pushes and Restrict deletions. GitHub's rule reference confirms both rules apply to tags. This is exactly the protection that would stop a release tag being repointed at new code, which is what happened in the tj-actions incident.

Keep the bypass list short. If every admin can bypass, a stolen admin account still can too.

You can check what's in place from the command line:

# List rulesets on a repository (needs the GitHub CLI, signed in)
gh api repos/OWNER/REPO/rulesets --jq '.[] | {name, target, enforcement}'

4. Turn on secret scanning and push protection

Secrets committed to a repository are found by automated scanners quickly, especially in public repos. GitHub's push protection blocks a push that contains a recognised secret (cloud keys, tokens and so on) before it lands.

  • On public repositories, push protection is free and can be enabled in bulk at the org level.
  • On private repositories, repository-level push protection requires GitHub Secret Protection, a paid add-on.
  • Push protection for users is on by default and stops you pushing secrets to any public repo, even ones you don't own.

If you can't justify the paid add-on for private repos, at minimum run a scanner such as gitleaks as a pre-commit hook or CI step. And remember the rule that matters most: a secret that was ever pushed is a leaked secret. Deleting the commit doesn't help. Rotate it.

5. Lock down GitHub Actions

Actions is where secrets live and where third-party code runs with access to them. Four changes cover most of the risk.

Give the GITHUB_TOKEN the least access it needs

Every workflow run gets a GITHUB_TOKEN, and GitHub recommends granting it the least access required. Any action you call can read it, even if you never pass it in explicitly. Set the org default to read-only (Settings → Actions → General → Workflow permissions), then grant extra scopes per job:

# Top of the workflow: read-only by default
permissions:
  contents: read

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write   # only this job can push tags/releases
    steps:
      - uses: actions/checkout@<full-40-character-commit-sha> # v4

Pin third-party actions to a full commit SHA

A tag like @v1 is just a pointer, and whoever controls the repository can move it. In March 2025, that's exactly what happened to tj-actions/changed-files and reviewdog/action-setup. A full 40-character commit SHA can't be repointed. Pin to the SHA and keep the version in a comment so humans can still read it, as in the example above.

Let Dependabot keep the pins current

SHA pinning only becomes a chore if you update by hand. Dependabot understands pinned actions and will raise PRs to bump them:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Be careful with self-hosted runners and untrusted triggers

The second wave of Shai-Hulud in November 2025 used stolen tokens to register a self-hosted runner on victims' machines and add a workflow that ran commands taken from GitHub Discussions posts. It turned the victim's own CI into a remote shell. Two takeaways:

  • Don't attach self-hosted runners to public repositories, and review the runner list in org settings regularly. A runner you didn't add is a serious red flag.
  • Never interpolate user-controlled text (issue titles, PR bodies, discussion text) directly into a run: step. Pass it through an environment variable and treat it as data.

6. Clean up apps, keys and people

Access accumulates. Once a quarter, go through:

  • Members and outside collaborators. Remove anyone who has left or finished their project. Contractors are the usual leftovers.
  • Installed GitHub Apps and authorised OAuth apps. CISA's guidance is to remove any you don't need.
  • Deploy keys and webhooks on each repository. An old webhook pointing at a service you no longer use is a data leak waiting to happen.
  • Actions secrets. Delete unused ones, and prefer short-lived OIDC credentials for cloud deployments over long-lived access keys where your provider supports it.

If you're already doing staff offboarding properly, add GitHub to that checklist so it happens on the person's last day, not at the next review.

7. Know how you'd spot a bad push

Prevention fails eventually, so make sure you'd notice. Every repository has an activity view: click Activity on the repo's main page, then filter by Force pushes. Each entry has a Compare changes option that shows exactly what was rewritten.

It's worth a look after any credential scare, and it's how you'd confirm the scope of an incident like ours quickly. If a force push shows up that nobody on the team made, treat it as an incident: revoke the account's tokens and sessions, rotate any secrets the affected repos could reach, and restore the branch from a known-good commit.

The short version

If you only have an hour, do these five things:

  1. Require 2FA for the org.
  2. Restrict classic PATs and cap token lifetimes.
  3. Add a ruleset blocking force pushes and deletions on your default branch and release tags.
  4. Set the default GITHUB_TOKEN to read-only.
  5. Remove ex-staff, unused apps and stale secrets.

None of it is glamorous, and none of it needs an enterprise plan. It does turn "one stolen token compromises everything" into "one stolen token gets someone not very far, and we notice".

If you'd like a second pair of eyes on your GitHub org, CI pipelines or dependencies, our Tech Audit covers security and dependency review for a flat $1,800, or you can tell us what you're working with.