You are currently viewing The Right Way to Handle Permissions in GitHub Actions: A Practical Guide to Staying Secure

The Right Way to Handle Permissions in GitHub Actions: A Practical Guide to Staying Secure

Hey! I’m Shreya Pohekar, and I work as a Security Researcher at Microsoft. Over time, while working across CI/CD pipelines and reviewing developer workflows, I realized that one of the most overlooked aspects of GitHub Actions security is permissions. Developers often give Actions far more access than necessary, leaving room for privilege escalation and repository compromise.

In this blog, I’ll walk you through how GitHub permissions work, why least privilege matters, why pull_request is still safe even with write-all, and how you can structure your workflow permissions correctly using simple, real-world examples.

If you haven’t read my detailed breakdown of pull_request vs pull_request_target, you can check it out here:
How Attackers Exploit pull_request_target.
It will give you useful context for understanding how permissions interact with workflow triggers.


1. Why Permissions Matter in GitHub Actions

When a workflow runs, GitHub provides an authentication token (GITHUB_TOKEN).
This token has a set of permissions, such as:

  • contents: read/write
  • pull-requests: write
  • packages: write
  • issues: write
  • checks: write

These permissions define exactly what that workflow can do.
If your workflow gets compromised, these permissions determine how much damage an attacker can cause.

For example:

  • A token with contents: write allows modifying repo files.
  • A token with issues: write allows creating malicious issues or comments.
  • A token with packages: write allows pushing malicious packages.

So the goal is simple:
Give the workflow only the permissions it needs—nothing more.


2. Using Least Privilege (The Recommended Standard)

GitHub allows defining permissions globally or at the job level:

Global least-permission setup

permissions:
  contents: read
  pull-requests: read

Job-level permissions

jobs:
  test:
    permissions:
      contents: read

By default, GitHub Actions used to grant broad write permissions.
Now, GitHub has moved toward a stricter model, but developers still often override permissions unconsciously.

Why least privilege is important

If your workflow gets compromised through:

  • Dependency installation
  • A malicious script
  • A compromised Action
  • An untrusted PR

…the attacker will only be able to use capabilities the token allows.

If your token has only read permissions:

  • The attacker cannot modify the repo
  • They cannot push new branches
  • They cannot create releases
  • They cannot comment or create issues

3. Why You Are Still Safe Even With write-all in a pull_request Trigger

This is the part many people misunderstand.

Even though GitHub allows you to configure:

permissions: write-all

…you are still safe under the pull_request trigger.

Here’s why:

1. GitHub automatically downgrades permissions for workflows triggered by untrusted PRs.

If someone from a fork opens a PR:

  • The workflow runs in a read-only security sandbox.
  • Secrets are not available.
  • GITHUB_TOKEN is forced to read-only, even if you explicitly set it to write-all.

This is why pull_request is considered the safe event.

Example

You write:

permissions: contents: write
on: pull_request

But a contributor opens a PR from a fork.

GitHub silently converts permissions to:

contents: read

Regardless of your config.

This is a built-in protection mechanism.

2. Workflow code is taken from the PR branch but executed with downgraded privileges

So even if the PR contains malicious workflow changes:

  • It cannot push changes
  • It cannot modify branches
  • It cannot create tags
  • It cannot deploy anything

This is why pull_request is safer than pull_request_target (explained in detail in the linked blog).


4. When Permissions Become Dangerous

The real trouble begins when combining:

  • pull_request_target
  • With high permissions
  • With actions/checkout checking out the attacker’s branch

This exposes your environment to attacks because:

pull_request_target runs in privileged context and does not downgrade permissions.

If your workflow contains:

on: pull_request_target

permissions: write-all

steps:
  - uses: actions/checkout@v4
    with:
      ref: ${{ github.event.pull_request.head.sha }}

  - run: ./build.sh

You have essentially given:

  • Write access
  • Token permissions
  • Ability to run arbitrary attacker code

This is why attackers target such workflows.

I have explained this attack path in detail here:
How Attackers Exploit pull_request_target.


5. Example: Safe vs Unsafe Permissions

Unsafe Example

on: pull_request_target
permissions: write-all

steps:
  - uses: actions/checkout@v4
    with:
      ref: ${{ github.event.pull_request.head.sha }}

  - run: npm install
  - run: npm run build

Why unsafe?

  • Untrusted code gets executed with write permissions.
  • PR code can steal secrets or modify the repo.

Safe Example

on: pull_request
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

Why safe?

  • The PR code runs in a downgraded environment.
  • No secrets.
  • Token is read-only.
  • Cannot modify the repo even if compromised.

6. Best Practices for Handling Permissions Safely

1. Start with contents: read

In most cases, workflows need only read access.

2. Grant write permissions only to specific jobs

jobs:
  release:
    permissions:
      contents: write

3. Never give write permissions in pull_request_target

If you truly need metadata updates, keep them read-only or strictly scoped.

4. Avoid using write-all unless absolutely necessary

Always restrict to the minimum.

5. Avoid running untrusted code in privileged workflows

This is the core principle behind all supply-chain security.

Here’s a concluding paragraph you can add to the permissions blog:


Managing permissions in GitHub Actions doesn’t need to be complicated, but it does require discipline. When you default to least privilege, audit what your workflows actually need, and understand how triggers like pull_request behave, you significantly reduce the chances of accidental exposure. Even if GitHub grants broader write permissions by default in certain contexts, the isolation guarantees of pull_request ensure your secrets remain protected, giving you a safer baseline to build on. Ultimately, good security in CI/CD is less about fear and more about thoughtful design—knowing what to trust, when, and why.

Hope you enjoyed reading this. See you in the next one. Until then, happy hunting.

shreyapohekar

I’m Shreya Pohekar, a Security Researcher at Microsoft. I’m passionate about breaking down complex security concepts into simple, relatable stories and sharing my journey through blogging. Writing helps me connect with others in the community, inspire aspiring security professionals, and reflect on the lessons I pick up along the way.

Leave a Reply