home / writing / github-actions-and-workflow-security

GitHub Actions and Workflow Security

A practical tour of GitHub from accounts and authentication through Actions workflows, runners and environments. It closes with the secrets handling and hardening habits that keep pipelines safe.

Accounts, plans and permissions

GitHub has three account types. A personal account is one person's identity and can own repositories. An organization is a shared space where teams collaborate and permissions are layered. An enterprise account sits above organizations and lets administrators set policy and billing centrally.

Plans (Free, Pro, Team, Enterprise) mostly differ in support level, included Actions minutes and storage, and advanced controls on private repositories such as required reviewers and protected branches; SAML SSO requires an Enterprise plan. Check the current plan page for exact allowances rather than relying on remembered numbers.

Repository access is granted through five roles, from least to most privileged:

RoleIntended for
ReadViewing and discussing the project
TriageManaging issues and pull requests without pushing code
WriteContributors who push to the repository
MaintainManaging the repo without destructive settings
AdminFull control, including security settings

Grant access to teams rather than individuals, keep organization owners to a small group, and use outside-collaborator access for contractors who should only see specific repositories.

Authentication options

  • Password plus two-factor authentication for the web UI. Prefer security keys or a TOTP app over SMS.
  • Personal access tokens (PATs) for the API and for HTTPS Git operations. Fine-grained tokens scoped to specific repositories and permissions are safer than broad classic tokens, and should have an expiry.
  • SSH keys for Git over SSH.
  • Deploy keys: SSH keys attached to a single repository, handy for a server that only needs to pull one repo.
  • SAML single sign-on and SCIM for organizations and enterprises. SAML redirects members to the identity provider before they can reach organization resources; SCIM automates provisioning and deprovisioning so access follows the directory. PATs and SSH keys must be explicitly authorized for an SSO-protected organization.

Inside a workflow you rarely need any of these: every run receives a short-lived GITHUB_TOKEN automatically.

Workflow anatomy: events, jobs, steps

A workflow is a YAML file under .github/workflows/. It declares the events that start it, one or more jobs, and each job's ordered steps. A step either runs a shell command (run) or calls a reusable action (uses).

name: ci
on:
  push:
    branches: [main]
    paths: ['src/**']
  pull_request:
    types: [opened, synchronize, reopened]
  workflow_dispatch:
  schedule:
    - cron: '0 3 * * 1'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
      - run: npm ci && npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploying"

Key points:

  • Jobs run in parallel by default; use needs to order them.
  • Events accept activity types and filters (branches, tags, paths and their -ignore forms).
  • workflow_dispatch adds a manual "Run workflow" button with optional inputs; repository_dispatch lets an external system trigger a run through the API; schedule uses cron syntax in UTC.
  • if: conditions use contexts such as github.ref and github.event_name.
  • Jobs do not share a filesystem. Pass files between jobs with actions/upload-artifact and actions/download-artifact, and small values with job outputs.

Actions come in three flavours: JavaScript actions (fast, run on the runner directly), container actions (bring their own environment, Linux only) and composite actions (bundle several steps behind one reference).

Runners and environments

A runner is a machine with the Actions runner agent that executes a job. GitHub-hosted runners give each job a fresh virtual machine, so there is nothing to maintain and no state leaks between runs. Self-hosted runners run on your own hardware or cloud, which gives access to private networks and special hardware but makes you responsible for patching and isolation.

Avoid self-hosted runners on public repositories. A pull request from a fork can run arbitrary code on a machine inside your network. If you must use them, make them ephemeral (one job, then destroyed) and keep them in an isolated network segment.

Environments (for example staging, production) model deployment targets. A job opts in with environment: production. Environments can require manual approval from named reviewers, restrict which branches may deploy, add a wait timer, and hold their own secrets and variables that are only visible to jobs targeting that environment.

Matrix builds and reusable workflows

A strategy.matrix expands one job definition into many runs, one per combination of values, which is ideal for testing across versions and operating systems. include and exclude adjust individual combinations.

strategy:
  matrix:
    node: [22, 24]
    os: [ubuntu-latest, windows-latest]

This produces four jobs. To share logic across repositories or pipelines, write a reusable workflow triggered by workflow_call and call it from another workflow at the job level:

# .github/workflows/deploy.yaml (the callee)
on:
  workflow_call:
    inputs:
      target:
        type: string
        required: true
    secrets:
      deploy_token:
        required: true

# caller
jobs:
  release:
    uses: my-org/shared-workflows/.github/workflows/deploy.yaml@v1
    with:
      target: staging
    secrets:
      deploy_token: ${{ secrets.DEPLOY_TOKEN }}

Pin the callee to a tag or commit SHA so a change in the shared repo cannot silently alter every pipeline that uses it.

Secrets and variables

Secrets are encrypted values defined at repository, environment or organization level (Settings, Secrets and variables, Actions). Reference them through the secrets context and pass them to steps as environment variables:

steps:
  - name: Call API
    env:
      API_TOKEN: ${{ secrets.API_TOKEN }}
    run: curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com/health

Non-sensitive configuration belongs in variables (the vars context) instead, so it stays readable. GitHub masks known secret values in logs, but masking is best-effort: transformed values (base64, substrings) may not be masked, so never print them.

Hardening workflows and repositories

  1. Least-privilege token. Set permissions: at workflow or job level, starting from contents: read and adding only what is needed.
  2. Prefer OIDC to stored cloud keys. With id-token: write, a job can exchange a short-lived identity token for temporary cloud credentials, so no long-lived key is stored at all.
  3. Pin third-party actions to a full commit SHA, and review an action's action.yml and source before adopting it. Tags can be moved.
  4. Beware script injection. Do not interpolate untrusted values such as issue titles or branch names directly into run:. Pass them through an env: variable and quote it in the shell.
  5. Be careful with pull_request_target and fork PRs. pull_request_target runs in the context of the base repository, with access to secrets and a token that can have write permissions, even for pull requests from forks; do not check out and execute untrusted fork code in that context.
  6. Protect branches. Require pull requests, status checks and code-owner review, and restrict who can push. Use a CODEOWNERS file for sensitive paths such as .github/workflows/.
  7. Turn on the built-in scanners: Dependabot alerts and version updates, secret scanning with push protection, and code scanning with CodeQL.
  8. Control cost and noise with concurrency groups to cancel superseded runs, path filters, and [skip ci] in commit messages for documentation-only changes.

If a secret is ever committed or printed, treat it as compromised: revoke and rotate it first, then clean up history.