A GitHub organisation is a credential store, a build system and a release channel at once, and most GitHub compromises come down to one token, one runner or one workflow trusted more than it should be. For the wider picture see the CI/CD Security Guide; this guide goes deeper on GitHub Actions security and the platform itself: the credentials GitHub issues, GitHub token permissions, GitHub Apps, OIDC, workflow attacks, third-party actions, runners, rulesets and the audit log. It is for platform engineers, organisation owners and application security teams who want GitHub Actions hardening settings they can apply.
Key takeaways
- Every GitHub credential is a non-human identity. Tokens, App keys and deploy keys differ sharply in scope and lifetime; choose deliberately and inventory them.
- Default to read, grant per job. Set the organisation default for
GITHUB_TOKENto read-only and declarepermissionsin every workflow, with write scopes only on the job that needs them. - Replace cloud keys with OIDC, and pin the trust. Federated tokens remove stored cloud secrets only if the cloud trust policy checks the subject, ideally repository and environment.
- Third-party actions run with your secrets. Enforce full-length SHA pinning through the allowed actions policy, block known-bad actions, and review what you allow; the tj-actions compromise showed how fast a moved tag spreads.
- Self-hosted runners are production servers. Use just-in-time or ephemeral runners, restrict runner groups to named repositories and workflows, and never attach them to public repositories.
The GitHub credential map
Each row below is a machine credential with its own failure mode. See the CI/CD Pipeline Identity Security Guide for the identity model across platforms.
| Credential | Scope | Lifetime | Use it for | Main risk |
|---|---|---|---|---|
GITHUB_TOKEN | The workflow's repository, limited by permissions | Ends with the job; at most 6 hours hosted, 24 hours self-hosted | Anything inside the same repository | Write scopes exposed to injected or third-party code |
| GitHub App installation token | Repositories and permissions granted to the installation, narrowable per token | 1 hour | Cross-repository automation, bots, integrations | Leaked app private key mints new tokens |
| Fine-grained personal access token | One user or organisation owner, optionally selected repositories, specific permissions | Set by user, capped by organisation policy | Personal scripts where an App is impractical | Acts as the user; survives role changes |
| Classic personal access token | All repositories in every organisation the user can access, by broad scope | Optional expiry; removed after a year unused | Only the few cases fine-grained tokens do not support | Broadest blast radius on the platform |
| Deploy key | One repository, read-only unless write is ticked | No expiry | Read-only checkout on a server | Usually no passphrase; not tied to membership |
| OIDC ID token | A single job, exchanged for cloud credentials | Minutes | Cloud deployments | Loose trust conditions in the cloud role |
GitHub's own guidance points the same way: use fine-grained tokens instead of classic ones, and for organisation resources or long-lived integrations, "you should use a GitHub App." Treat shared tokens and bot accounts as service accounts. See the Service Account Security Guide.
GITHUB_TOKEN and workflow permissions
"The GITHUB_TOKEN secret is a GitHub App installation access token", created at the start of each job. Its permissions come from the repository or organisation default ("Workflow permissions") and the workflow's permissions key. Older organisations may still grant read and write by default, so check the setting at every level, and turn off "Allow GitHub Actions to create and approve pull requests" unless an automation genuinely needs it.
These are the GitHub Actions permissions to watch. Once a workflow lists any scope, every unlisted scope becomes none. Set permissions: {} or contents: read at the top of each workflow and raise scopes per job:
| Scope | Grant write only when | Risk if over-granted |
|---|---|---|
contents | The job pushes commits, tags or creates releases | Code and tag tampering, workflow file changes |
pull-requests | The job comments, labels or opens pull requests | Self-approval and review bypass paths |
id-token | The job requests an OIDC token for cloud access | Any step in the job can mint cloud credentials |
packages | The job publishes to GitHub Packages | Malicious package or image publication |
actions | The job manages workflow runs, caches or artifacts | Cache and artifact manipulation |
security-events | The job uploads code scanning results | Dismissal or suppression of findings |
Events triggered with GITHUB_TOKEN do not start new workflow runs, except workflow_dispatch and repository_dispatch, and pull requests it opens, synchronises or reopens create runs that need approval from a user with write access. Do not route around this brake with a broad personal access token.
GitHub Apps, fine-grained tokens and deploy keys
GitHub Apps
An App authenticates with a JWT signed by its private key, then requests an installation token that "will expire after 1 hour". Request each token with the repositories or repository_ids and permissions parameters so it carries only what the task needs; it "cannot be granted permissions that the app was not granted". Keep the private key in a secrets manager or HSM and delete keys you cannot account for: the leaked GitHub App private keys case found 474 forgotten keys that still worked. Require owner approval for installations and review GitHub App permissions upgrades in the audit log.
Personal access token policy
- Restrict classic tokens: organisation owners can stop classic tokens reaching organisation resources; by default both kinds are enabled, and classic tokens are not subject to approval or an expiry requirement.
- Approve fine-grained tokens: "Require administrator approval" is the default; a pending token can read only public resources.
- Cap lifetime: the default maximum for fine-grained tokens is 366 days; shorten it to 90 or fewer. Non-compliant tokens are blocked but not revoked, so clean them up.
- Know the gaps: fine-grained tokens do not cover multiple organisations, Packages or the Checks API, so keep a short, owned list of classic token exceptions.
Deploy keys
GitHub lists the drawbacks plainly: deploy keys are "usually not protected by a passphrase", are "credentials that don't have an expiry date", and are not linked to organisation membership, so they survive the creator leaving. Replace write-enabled keys with a GitHub App, because a write key pushes without review.
OIDC from GitHub Actions to the cloud
With OIDC a job requests the GitHub Actions OIDC token (it needs id-token: write) and exchanges it with AWS, Azure or Google Cloud for short-lived credentials. The security sits entirely in the cloud trust condition, which compares claims such as sub, aud, repository_id, repository_owner_id, environment and job_workflow_ref.
| Trust condition | Verdict | Why |
|---|---|---|
| Audience only, or a wildcard owner | Never | Any repository, possibly any GitHub user, can assume the role |
repo:ORG/REPO:pull_request | Avoid for privileged roles | Matches jobs triggered by pull requests, including contributor code |
repo:ORG/REPO:ref:refs/heads/main | Acceptable | Limits to the default branch, but any workflow on it qualifies |
repo:ORG/REPO:environment:prod | Preferred for deployment | Inherits environment reviewers and branch rules |
Custom sub with job_workflow_ref | Preferred for central pipelines | Only an approved reusable workflow at an approved ref can deploy |
GitHub's reference says to define at least one subject condition so "untrusted repositories can't request access tokens". Repositories created after 15 July 2026 use an immutable default subject that embeds IDs, repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH, so a re-registered name cannot produce the same subject. Customising the subject (include_claim_keys through PUT /repos/{owner}/{repo}/actions/oidc/customization/sub or the organisation endpoint) changes the whole sub format, so create the matching cloud condition first or deployments will fail. See the Cloud Workload Identity Guide.
Workflow attacks: injection, pull_request_target and cache poisoning
These GitHub Actions workflow attacks map to ATT&CK T1677 Poisoned Pipeline Execution: attacker code or input runs inside a trusted workflow and uses its token, secrets or cache.
- GitHub Actions script injection: values such as pull request titles, issue bodies, comments and branch names are attacker-controlled. Interpolating them with
${{ }}inside arun:block makes them part of the shell script. Pass them through a quoted environment variable instead; CodeQL has queries for this pattern. The Ultralytics PyPI compromise started with an injectable workflow next to a publishing credential. - pull_request_target and workflow_run: both run with the base repository's token and secrets. They are safe only if they never check out or execute code from the pull request; build untrusted code under
pull_requestand treat its artifacts as untrusted data. - GitHub Actions cache poisoning: runs can restore caches created on their own branch or the default branch, and GitHub notes that privileged triggers "share the same cache of the main branch". A workflow that runs untrusted input in the default branch context can therefore write a cache entry that a later release job restores. GitHub's own warning is that "Anyone with read access can create a pull request on a repository and access the contents of a cache." Never cache secrets, give release workflows their own cache keys or no cache, and do not grant
actions: writeto jobs that handle untrusted input.
Require approval before fork pull request workflows run (first-time contributors at minimum); fork runs receive no secrets other than a read-only GITHUB_TOKEN.
Third-party actions and the Marketplace
Third-party actions risk is simple: an action runs inside your job with your token and secrets, so every uses: line is a dependency with production access. The tj-actions/changed-files compromise (CVE-2025-30066) is the reference case: CISA set the exposure window as 12 March 2025 00:00 to 15 March 2025 12:00 UTC, noted that secrets "may be obfuscated as a double-encoded base64 payload", and said identified secrets "should be considered compromised". It was enabled by an earlier compromise of reviewdog/action-setup@v1 (CVE-2025-30154); see the reviewdog/action-setup compromise.
- Allowed actions policy: at enterprise or organisation level, allow GitHub-owned actions plus a named list rather than all actions. Since August 2025 the policy can block entries prefixed with
!(evaluated last) and enforce GitHub Actions SHA pinning, after which "any workflow that attempts to use an action that isn't pinned will fail". - Pin and update: pin actions to a commit SHA (all 40 characters) from the action's own repository with a version comment, and enable Dependabot version updates for the
github-actionsecosystem so pins do not rot. GitHub's secure use reference also describes workflow dependency lockfiles, which record the exact commit of every action including transitive ones. - Review before allowing: a GitHub Actions Marketplace listing or verified creator badge identifies the publisher, not the safety of the code. Check the source, maintainers, release process and any nested unpinned actions.
Runners: GitHub-hosted and self-hosted
A GitHub-hosted GitHub Actions runner gives a fresh virtual machine per job and is the right default. Self-hosted runner security matters because GitHub warns a self-hosted GitHub Actions runner "can be persistently compromised by untrusted code in a workflow". To harden runner fleets:
| Control | How on GitHub | What it prevents |
|---|---|---|
| Single-use runners | Just-in-time runners created through the REST API ("perform at most one job"), or ephemeral autoscaled runners such as Actions Runner Controller on Kubernetes | Persistence and cross-job secret theft |
| Runner groups | Restrict each group to selected repositories and, where available, selected workflows; separate production deploy runners | Untrusted repositories reaching privileged runners |
| No public repositories | Leave "Allow public repositories" off on runner groups | Anyone who opens a pull request running code on your hardware |
| Network egress control | Dedicated subnet, egress allowlist, cloud metadata endpoint blocked unless needed | Exfiltration and cloud credential theft from the host |
| Least host privilege | Non-root runner user, no Docker socket mounts, no shared credentials on disk | Escape from job to host and to other jobs |
| Rebuild from image | Immutable, patched runner images rebuilt regularly; runner application kept current | Drift and known vulnerabilities |
Monitor runner hosts like servers, with per-job process and network telemetry and alerts on new runner registrations. A self-hosted job can run for up to 5 days but GITHUB_TOKEN refreshes for only 24 hours, which tempts teams to add long-lived tokens; split long jobs instead.
GitHub Actions secrets
- Scope by level: anything that can use OIDC should not be a stored secret; organisation secrets should be visible to selected repositories only; deployment credentials belong in environment secrets, because "a workflow job cannot access environment secrets until approval is granted by a reviewer".
- One value per secret: GitHub advises against storing JSON, XML or YAML blobs as a secret, because redaction is less reliable for structured values; register any derived value as a secret so it is masked.
- Block and detect leaks: enable secret scanning and repository push protection (it requires GitHub Secret Protection and is off by default at repository level), restrict who may bypass it with delegated bypass, and review bypasses, which are logged and emailed. See the Secrets Management Guide.
Repository and organisation controls
Rulesets and branch protection rules
Branch protection rules still work, but repository rulesets are better: "Multiple rulesets can apply to the same branch at the same time", "the most restrictive version of the rule applies", and organisation rulesets on Team and Enterprise cover many repositories. Apply an organisation ruleset to default and release branches requiring pull requests with approval, code owner review, dismissal of stale approvals, status checks and blocked force pushes and deletions. Add tag rules so release tags cannot be moved or deleted (the Laravel-Lang compromise rewrote every release tag with one stolen token), and push rulesets to block file types, paths or sizes across a private repository's fork network. Keep bypass lists short and reviewed.
CODEOWNERS
A CODEOWNERS file can sit in .github/, the root or docs/ (the first one found wins), and review requests use the copy on the pull request's base branch. Owners need write access; any invalid line is skipped, and files over 3 MB are ignored entirely, so validate the file in CI. Assign owners for .github/workflows/, .github/actions/ and the CODEOWNERS file itself, and enable "Require review from Code Owners", noting that approval from any one listed owner satisfies it.
GitHub organisation security and enterprise security settings
- Authentication: require two-factor authentication for members; on Enterprise Cloud enforce SAML single sign-on, or use Enterprise Managed Users so accounts are provisioned and removed from your identity provider.
- Base permissions: set member base permission to read or none, restrict repository creation, visibility changes, forking of private repositories and deletion to owners.
- Network: use the IP allow list where your plan supports it.
- Integrations: require owner approval for OAuth app access and GitHub App installations, and review third-party access in the same cycle as personal access tokens. See the SaaS and OAuth App Governance Guide.
Detection and measurement
Stream the GitHub audit log to your SIEM (audit log streaming is an Enterprise Cloud feature) and alert on:
- Secrets changes:
org.update_actions_secretandenvironment.create_actions_secret,environment.update_actions_secretandenvironment.remove_actions_secret, plusenvironment.remove_protection_rule. - App changes:
integration_installation.create,integration_installation.repositories_addedandintegration_installation.version_updated(an App's permissions grew). - Workflow changes: commits that add or modify files under
.github/workflows/from accounts that do not normally touch them, the pattern behind GhostAction and Megalodon. - Bulk access: clone spikes in
git.*events, new webhooks (hook.*) andip_allow_list.*changes.
Measure monthly: workflows with explicit permissions (target 100%); third-party actions pinned to a SHA (target 100%, then enforce); classic tokens with organisation access (target zero outside owned exceptions); cloud deployments still using stored keys instead of OIDC; self-hosted runners that are not single-use; and median time to revoke a leaked GitHub credential.
Implementation roadmap
- Inventory (weeks 1 to 2): export Apps, OAuth apps, tokens, deploy keys, runners, secrets and workflows using
pull_request_target,workflow_runor third-party actions; turn on audit log streaming. - Tokens (weeks 2 to 4): set the default
GITHUB_TOKENto read-only on a pilot set of repositories, fix the failures by adding job-levelpermissions, then roll out organisation-wide. Rollback is per repository by reverting the setting while workflows are fixed. - Actions policy (weeks 3 to 6): pin actions with automated pull requests, move the allowed actions policy to a named list, then enable SHA pinning enforcement on a pilot organisation before the enterprise. Failing unpinned workflows are the test; the checkbox is the rollback.
- Cloud access (weeks 4 to 10): replace stored cloud keys with OIDC, environment by environment, using environment or
job_workflow_refsubjects; delete each old key only after a clean release. - Runners (weeks 6 to 12): move self-hosted fleets to just-in-time or ephemeral runners, split runner groups by trust level and add egress control.
- Governance (ongoing): organisation rulesets, CODEOWNERS on workflow paths and quarterly review of exceptions.
Decision guidance
- GitHub App or token: use an App for anything shared, long-lived or organisation-wide; a fine-grained token only for one person's short-term scripting; a classic token only for documented gaps; a deploy key only for read-only server checkout.
- Small organisations: a read-only token default, SHA pinning enforcement, push protection, required two-factor authentication and one ruleset give most of the benefit.
- Large enterprises: add Enterprise Managed Users or enforced SSO, an enterprise actions policy, a central deployment workflow trusted through
job_workflow_ref, runner groups per business unit and SOC-owned audit detections.
Framework mapping
| Control area | NIST SP 800-53 | CIS Controls v8.1 | ISO/IEC 27001:2022 | ATT&CK |
|---|---|---|---|---|
| Token scoping, App permissions, PAT policy | AC-6, IA-5 | Control 5, Control 6 | A.5.15, A.8.2 | T1552.001 |
| Rulesets, CODEOWNERS, review | CM-5, SA-10 | Control 16 | A.8.4, A.8.32 | T1195.002 |
| Workflow injection and trigger hygiene | SA-11, SI-10 | Control 16 | A.8.25, A.8.28 | T1677 |
| Third-party actions | SR-3, SA-9 | Control 16 | A.5.21 | T1195.002 |
| Audit log and detection | AU-6, AU-12 | Control 8 | A.8.15, A.8.16 | T1677 |
Common failure patterns
- Leaked tokens left live: in the Home Depot token exposure a leaked GitHub token left internal systems open for more than a year.
- Over-scoped release tokens: the Amazon Q Developer extension compromise abused an over-scoped GitHub token.
- Unpinned actions: workflows referencing a mutable tag ran attacker code during the tj-actions window; SHA pinning would have prevented it.
- Stolen accounts planting workflows: GhostAction pushed workflows that exfiltrated 3,325 secrets.
- One token, many repositories: in the GitHub internal repositories breach a single stolen CLI token led to thousands of repositories.
Practitioner checklist
- Set the organisation default
GITHUB_TOKENto read-only and require explicitpermissionsin every workflow. - Disable "Allow GitHub Actions to create and approve pull requests" unless an owned automation needs it.
- Restrict classic personal access tokens, keep fine-grained approval on, and cap lifetime at 90 days or less.
- Move shared automation to GitHub Apps with per-token repository and permission narrowing; protect and rotate App private keys.
- Replace stored cloud keys with OIDC trusted on environment or
job_workflow_refsubjects. - Enforce SHA pinning and a named allowed actions list; enable Dependabot updates for actions.
- Remove
pull_request_targetjobs that check out pull request code, and keep caches out of release workflows. - Run self-hosted runners as just-in-time or ephemeral, in restricted runner groups, never for public repositories.
- Apply organisation rulesets with tag protection, and CODEOWNERS on
.github/workflows/and the CODEOWNERS file. - Stream the audit log and alert on secret, App, workflow file and runner changes.
Standards and references
- GitHub Docs: Secure use reference for GitHub Actions
- GitHub Docs: GITHUB_TOKEN
- GitHub Docs: OpenID Connect reference
- GitHub Docs: Authenticating as a GitHub App installation
- GitHub Docs: Setting a personal access token policy for your organization
- GitHub Docs: About rulesets
- CISA: Supply Chain Compromise of Third-Party GitHub Action, CVE-2025-30066
- MITRE ATT&CK: T1677 Poisoned Pipeline Execution
Related NHI Mgmt Group resources: CI/CD Security Guide · CI/CD Pipeline Identity Security Guide · Software Supply Chain Security Guide · Secrets Management Guide