If you’re running GitHub Actions in a real repo especially one that accepts PRs here’s the blunt version.
The three most dangerous mistakes I see:
-
Defaulting to write permissions everywhere.
-
Using without fully understanding it.
-
Letting unpinned third-party actions run with secrets.
Everything else flows from those.
The 10 settings teams miss:
-
Set default workflow permissions to read-only.
-
Grant write permissions only at the job level, only where required.
-
Treat
pull_request_targetas radioactive unless you know exactly why you need it. -
Assume forks are hostile design workflows accordingly.
-
Use environments + approvals as a real security boundary, not decoration.
-
Restrict which actions can run in your org.
-
Pin actions to full commit SHAs, not tags.
-
Pin reusable workflows too (yes, those count).
-
Prefer OIDC over long-lived cloud keys.
-
Harden runners and logs, especially self-hosted ones.
If you only fix three things this quarter:
lock down permissions, kill unsafe pull_request_target usage, and pin your actions.
Everything else is defense in depth valuable, but secondary.
Why GitHub Actions security breaks in real teams
Most teams don’t “choose” insecure GitHub Actions setups. They drift into them.
It usually starts innocently:
-
Someone copies a workflow from a blog post.
-
Another person adds a deploy step under deadline pressure.
-
A third person “temporarily” switches to.
Six months later, nobody remembers why the workflow looks like that but it’s now running on every PR with write permissions and cloud credentials.
GitHub Actions feels safe because:
-
It’s first-party.
-
It’s deeply integrated.
-
It mostly works out of the box.
But the defaults optimize for convenience, not containment.
I’ve seen:
-
Tokens with repo-admin scope exposed through a PR echo statement.
-
Self-hosted runners popped because someone ran forked code as root.
-
Third-party actions hijacked upstream and silently exfiltrating secrets.
-
A single compromised workflow used to pivot across dozens of repos in an org.
None of this required zero-days. Just default behavior plus trust.
If you don’t draw explicit boundaries, GitHub won’t do it for you.
And the documentation doesn’t always make the danger obvious. Some of the most footgun-heavy features reusable workflows, marketplace actions) look clean and official right up until they burn you.
This post is about the settings that actually matter in practice, not the ones that look good in a checklist.
Default workflow permissions to read-only
most misunderstood part of GitHub Actions.
Every workflow gets one automatically. It looks harmless. It is not.
By default, that token can often:
-
Write to the repo
-
Create or modify issues and PRs
-
Push commits
-
Create releases
That means any step that can run code can use that power.
In my experience, most workflows don’t need write access at all especially CI jobs. Tests, linting, builds, static analysis? Read-only is enough.
Set this once, globally:
Why this matters:
-
If someone sneaks malicious code into a PR and it executes, the token is mostly useless.
-
If a third-party action misbehaves, its blast radius shrinks dramatically.
-
Accidental damage (bad scripts, bad loops) is contained.
When is write access justified?
-
Creating releases or tags
-
Updating PR comments or commit statuses
-
Publishing artifacts back to the repo
Even then, don’t enable it globally. Which leads to the next setting.
Use job-level permissions, not blanket permissions
Least privilege sounds abstract until you’ve debugged an incident.
Here’s the common anti-pattern:
Added at the workflow level “just to make things work.”
The problem is that every job and every step now has those rights, including:
-
Test jobs
-
PR validation jobs
-
Third-party actions
-
Steps that execute untrusted code
Attackers love this. If they get any execution, they get everything.
The fix is boring but effective:
grant permissions only on the job that truly needs them
Example mental model:
-
CI job → read-only
-
Build job → read-only
-
Release job → write
-
Deploy job → write + OIDC
This way:
-
A compromised test step can’t push code.
-
A malicious dependency can’t open PRs.
-
An unsafe action can’t mutate your repo.
Yes, it’s a little more verbose. That’s fine. Security boundaries usually are.
permissions inheritance is not always intuitive. If you’re unsure, explicitly set permissions on each job. Redundancy beats surprise.
-
Because it has access to secrets and write tokens while still reacting to untrusted input.
Here’s the classic mistake I’ve seen multiple times:
“Our PR checks need secrets, so we switched to.”
What actually happens:
-
Attacker opens a forked PR.
-
Workflow runs with secrets.
-
Workflow checks out PR code (or reads PR metadata).
-
Attacker controls what runs next.
Congratulations, you just handed secrets to the internet.
Never do this
-
Check out PR code in a job.
-
Run scripts influenced by PR content in a privileged context.
-
Use it as a “hack” to bypass fork restrictions.
If you must use:
-
Do not check out the PR.
-
Do not execute PR-controlled code.
-
Treat it as metadata-only (labels, comments, checks).
If that sounds restrictive, good. It should. Use accept that secrets aren’t available, and redesign the workflow. Security is cheaper than incident response.
Forks and secrets where teams accidentally re-enable risk
GitHub actually does one thing very right:
secrets are not exposed to workflows triggered by forks
That protection is solid until teams undo it.
Common ways I’ve seen this broken:
-
Switching to “temporarily”
-
Running deploy logic on PRs “just for testing”
-
Using to chain into privileged jobs
-
Passing artifacts from untrusted jobs into trusted ones
The dangerous pattern is always the same:
“We validated the code earlier, so it’s safe now.”
Validation does not make code trusted. Review does not make code trusted. Only merging into a trusted branch does.
If a workflow:
-
Executes forked code
-
And later runs with secrets
-
And consumes outputs from that code
You’ve built a secret-laundering pipeline.
The safer approach:
-
Split workflows by trust level.
-
Untrusted PR workflows: no secrets, no writes.
-
Trusted branch workflows: deploy, release, credentials.
Yes, this sometimes means slower feedback. That’s the trade-off. You’re choosing containment over convenience.
One rule of thumb I use:
If code hasn’t been merged, it shouldn’t touch credentials.
Use environments as a real security boundary
Most teams treat environments as labels. They’re more than that.
Environments give you:
-
Required reviewers
-
Secret scoping
-
Deployment visibility
-
An intentional pause before damage
That pause matters.
I’ve seen approvals catch:
-
Wrong branch deploys
-
Misconfigured regions
-
Accidental prod pushes from test workflows
Is it slower? Yes. By minutes.
Is it worth it? Also yes.
Think of environments as human circuit breakers. Automation is great until it isn’t.
Practical advice:
-
Use environments for any deploy that matters.
-
Require at least one human for prod.
-
Scope secrets to the environment, not the repo.
If your org resists approvals, start with just prod. Nobody misses fully automated prod deploys after their first incident.
Restrict which GitHub Actions can run
Every action you allow is code you didn’t write running in your environment.
Marketplace sprawl is real. I’ve seen repos with:
-
20+ actions
-
Maintained by random GitHub users
-
Last updated years ago
-
Running with write permissions
That’s supply-chain risk, plain and simple.
GitHub lets orgs:
-
Allow only verified creators
-
Allow only specific actions
-
Or block the marketplace entirely
You don’t need bureaucracy.
Start small:
-
Allow GitHub-maintained actions.
-
Allow actions from orgs you trust.
-
Block everything else by default.
If someone needs a new action, review it like you would a dependency. Because that’s exactly what it is.
Pin actions to full commit SHAs
Tags are mutable. That’s the whole problem.
When you use:
You are trusting:
-
The maintainer
-
Their account security
-
Their CI
-
Their future self
If the tag moves, your workflow silently changes.
This has already burned people. Publicly.
Pin to a full SHA:
Yes, it’s ugly. Yes, it’s worth it.
How to keep it manageable:
-
Update pins during dependency bumps.
-
Let bots open PRs for updates.
-
Review diffs like any other code change.
Security isn’t free. It’s controlled friction.
Pin reusable workflows too
Reusable workflows are just actions with better marketing.
They:
-
Execute code
-
Can request permissions
-
Can access secrets
If you reference them by branch or tag, you have the same problem as before.
Pin them to SHAs. Always.
I’ve seen teams lock down actions but completely forget reusable workflows. Attackers won’t.
Prefer OIDC over long-lived cloud keys
Secrets are high-value targets. Cloud keys especially.
Long-lived keys:
-
Live forever
-
Often have broad permissions
-
Get copied into logs, caches, forks, artifacts
OIDC flips the model:
-
No stored secret
-
Short-lived credentials
-
Scoped per job
-
Easy to revoke centrally
The big win is blast radius reduction.
If a job is compromised:
-
The token expires.
-
It can’t be reused elsewhere.
-
It can’t be stolen for later.
Yes, setup is annoying the first time. After that, it’s boring which is exactly what you want from security.
Harden runners and logs
Self-hosted runners deserve special suspicion.
Blunt rule:
Never run untrusted PR code on self-hosted runners.
I’ve seen:
-
Runners with prod network access
-
Running as root
-
Executing forked code
That’s not CI. That’s an invitation.
If you must use self-hosted runners:
-
Isolate them.
-
Lock down network access.
-
Rotate them often.
-
Separate trusted vs untrusted workloads.
Also:
Logs are data exfiltration channels. Redact aggressively. Avoid printing env vars. Be careful with set -x.
Secrets don’t need to be stolen if you print them yourself.
A secure-by-default reference workflow
Here’s how I think about a minimal, hardened CI workflow conceptually, not as a YAML dump.
Key choices:
-
Trigger on
-
Default permissions: read-only
-
No secrets
-
No deploy logic
-
No third-party actions unless pinned
The idea:
-
PRs get fast feedback.
-
No credential exposure.
-
No side effects.
Then, separately:
-
Uses environments
-
Requests write permissions explicitly
-
Uses OIDC for deploys
-
Pinned actions only
Two workflows. Two trust levels. Clear boundary.
If you blur that boundary, things get weird fast.
10-minute self-audit checklist
Answer these honestly:
-
Do any workflows default to write permissions?
-
Do PR workflows ever access secrets?
-
Are you using ? Do you know why?
-
Do untrusted jobs run on self-hosted runners?
-
Are actions pinned to SHAs?
-
Are reusable workflows pinned?
-
Can any job deploy without environment approval?
-
Are cloud keys long-lived secrets?
-
Can any workflow mutate the repo unnecessarily?
-
Do logs ever print sensitive data?
Fix the top three “yes” answers first. That’s where the real risk lives.
You Might Be Interested In
- How Phishing Attacks Trick Users?
- How Zero Trust Architecture Is Being Adopted by Governments?
- Why Identity Is the New Perimeter in Cybersecurity?
- How Network Security Protects Data?
- Why DevOps Improves Software Delivery?
Conclusion
GitHub Actions isn’t insecure by default, but it is easy to use unsafely without realizing it. Most of the real problems don’t come from advanced attackers or exotic exploits they come from reasonable engineers moving fast, copying examples, and trusting defaults that quietly grant too much power.
The uncomfortable truth is that CI/CD sits at the center of your trust graph. If an attacker gets code execution in your workflows, they’re often one or two misconfigurations away from your repo, your cloud account, or your production environment. That’s why small details permission scopes, trigger choices, where secrets live matter far more here than in most other parts of the stack.
You don’t need to lock everything down to the point of paralysis. What you do need is clear trust boundaries: untrusted code vs trusted code, read vs write, build vs deploy. Once those boundaries are explicit, GitHub Actions becomes predictable instead of scary.
If there’s one mindset to keep, it’s this: treat workflows like production code with production credentials. Review them, minimize them, and assume they’ll be abused if given the chance. Do that, and GitHub Actions becomes a powerful tool not your weakest link.
FAQs
What permissions should GitHub Actions workflows have?
In practice, most workflows need far less permission than they’re given. The safest baseline is read-only for everything, then explicitly adding write permissions only to the one job that truly needs it (for example, creating a release or pushing a tag). This limits blast radius: if a job is compromised, the attacker can’t automatically mutate your repo, open PRs, or push code.
What trips teams up is convenience. Giving globally “just works” and avoids debugging permission errors. But that convenience quietly turns every step including third-party actions and scripts into a repo maintainer. In real-world repos, that’s almost never justified.
Do GitHub Actions secrets work on pull requests from forks?
By default, no and that’s one of GitHub’s strongest security controls. Forked PRs run in a restricted context specifically because the code is untrusted. If secrets were exposed, any attacker could open a PR and immediately exfiltrate credentials.
Where teams get into trouble is trying to bypass this restriction instead of designing around it. Switching triggers, chaining workflows, or running privileged follow-up jobs often reintroduces the exact risk GitHub was preventing. If code hasn’t been merged into a trusted branch, it shouldn’t have access to secrets. Full stop.
Why is dangerous?
runs with the permissions and secrets of the base branch, not the PR branch. That sounds useful and it is but it’s extremely easy to misuse. The danger comes from combining trusted credentials with untrusted input in the same job.
I’ve seen teams treat as a drop-in replacement for just to “make secrets available.” That’s how you end up executing attacker-controlled logic with write access and deploy keys. Used correctly, should be limited to metadata-only tasks like labeling or commenting, never running PR code.
Should I pin actions to tags or commit SHAs?
Commit SHAs, always. Tags feel stable, but they’re mutable by design. If a maintainer’s account is compromised or a repo is taken over, a tag like v1 can be moved to malicious code without you changing anything. Your workflow will happily run it.
Pinning to a SHA trades convenience for certainty. You know exactly what code you’re running, and changes only happen when you explicitly update them. That predictability is critical in CI/CD, where small upstream changes can have outsized impact.
How do I keep pinned SHAs up to date without pain?
Treat pinned actions like any other dependency. Let automation open PRs to update them, review the diff, and merge when you’re comfortable. This shifts updates from “silent and automatic” to “visible and intentional,” which is exactly where security-sensitive changes belong.
Yes, it adds a little maintenance overhead. But that overhead buys you auditability and control. In my experience, teams that automate updates and review them regularly end up with both better security and fewer surprise breakages.

