{"id":1889,"date":"2025-11-25T18:38:18","date_gmt":"2025-11-25T18:38:18","guid":{"rendered":"https:\/\/shreyapohekar.com\/blogs\/?p=1889"},"modified":"2025-11-25T18:38:33","modified_gmt":"2025-11-25T18:38:33","slug":"the-right-way-to-handle-permissions-in-github-actions-a-practical-guide-to-staying-secure","status":"publish","type":"post","link":"https:\/\/shreyapohekar.com\/blogs\/the-right-way-to-handle-permissions-in-github-actions-a-practical-guide-to-staying-secure\/","title":{"rendered":"The Right Way to Handle Permissions in GitHub Actions: A Practical Guide to Staying Secure"},"content":{"rendered":"\n<p>Hey! I\u2019m 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.<\/p>\n\n\n\n<p>In this blog, I&#8217;ll walk you through how GitHub permissions work, why least privilege matters, why <code>pull_request<\/code> is still safe even with <code>write-all<\/code>, and how you can structure your workflow permissions correctly using simple, real-world examples.<\/p>\n\n\n\n<p>If you haven&#8217;t read my detailed breakdown of <code>pull_request<\/code> vs <code>pull_request_target<\/code>, you can check it out here:<br><strong><a href=\"https:\/\/shreyapohekar.com\/blogs\/how-attackers-exploit-pull_request_target-secure-your-github-ci-cd-workflows\/\">How Attackers Exploit pull_request_target<\/a><\/strong>.<br>It will give you useful context for understanding how permissions interact with workflow triggers.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\"><strong>1. Why Permissions Matter in GitHub Actions<\/strong><\/h1>\n\n\n\n<p>When a workflow runs, GitHub provides an authentication token (<code>GITHUB_TOKEN<\/code>).<br>This token has a set of permissions, such as:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>contents: read\/write<\/code><\/li>\n\n\n\n<li><code>pull-requests: write<\/code><\/li>\n\n\n\n<li><code>packages: write<\/code><\/li>\n\n\n\n<li><code>issues: write<\/code><\/li>\n\n\n\n<li><code>checks: write<\/code><\/li>\n<\/ul>\n\n\n\n<p>These permissions define exactly what that workflow can do.<br>If your workflow gets compromised, these permissions determine how much damage an attacker can cause.<\/p>\n\n\n\n<p>For example:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A token with <code>contents: write<\/code> allows modifying repo files.<\/li>\n\n\n\n<li>A token with <code>issues: write<\/code> allows creating malicious issues or comments.<\/li>\n\n\n\n<li>A token with <code>packages: write<\/code> allows pushing malicious packages.<\/li>\n<\/ul>\n\n\n\n<p>So the goal is simple:<br><strong>Give the workflow only the permissions it needs\u2014nothing more.<\/strong><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\"><strong>2. Using Least Privilege (The Recommended Standard)<\/strong><\/h1>\n\n\n\n<p>GitHub allows defining permissions globally or at the job level:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Global least-permission setup<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>permissions:\n  contents: read\n  pull-requests: read\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Job-level permissions<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>jobs:\n  test:\n    permissions:\n      contents: read\n<\/code><\/pre>\n\n\n\n<p>By default, GitHub Actions used to grant broad write permissions.<br>Now, GitHub has moved toward a stricter model, but developers still often override permissions unconsciously.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why least privilege is important<\/h3>\n\n\n\n<p>If your workflow gets compromised through:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Dependency installation<\/li>\n\n\n\n<li>A malicious script<\/li>\n\n\n\n<li>A compromised Action<\/li>\n\n\n\n<li>An untrusted PR<\/li>\n<\/ul>\n\n\n\n<p>\u2026the attacker will only be able to use capabilities the token allows.<\/p>\n\n\n\n<p>If your token has only <code>read<\/code> permissions:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The attacker cannot modify the repo<\/li>\n\n\n\n<li>They cannot push new branches<\/li>\n\n\n\n<li>They cannot create releases<\/li>\n\n\n\n<li>They cannot comment or create issues<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\"><strong>3. Why You Are Still Safe Even With <code>write-all<\/code> in a <code>pull_request<\/code> Trigger<\/strong><\/h1>\n\n\n\n<p>This is the part many people misunderstand.<\/p>\n\n\n\n<p>Even though GitHub allows you to configure:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>permissions: write-all\n<\/code><\/pre>\n\n\n\n<p>\u2026you are still safe under the <code>pull_request<\/code> trigger.<\/p>\n\n\n\n<p>Here\u2019s why:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>1. GitHub automatically downgrades permissions for workflows triggered by untrusted PRs.<\/strong><\/h3>\n\n\n\n<p>If someone from a fork opens a PR:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The workflow runs in a <strong>read-only<\/strong> security sandbox.<\/li>\n\n\n\n<li>Secrets are not available.<\/li>\n\n\n\n<li><code>GITHUB_TOKEN<\/code> is forced to <strong>read-only<\/strong>, even if you explicitly set it to <code>write-all<\/code>.<\/li>\n<\/ul>\n\n\n\n<p>This is why <code>pull_request<\/code> is considered the safe event.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Example<\/strong><\/h3>\n\n\n\n<p>You write:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>permissions: contents: write\non: pull_request\n<\/code><\/pre>\n\n\n\n<p>But a contributor opens a PR from a fork.<\/p>\n\n\n\n<p>GitHub silently converts permissions to:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>contents: read\n<\/code><\/pre>\n\n\n\n<p>Regardless of your config.<\/p>\n\n\n\n<p>This is a built-in protection mechanism.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>2. Workflow code is taken from the PR branch but executed with downgraded privileges<\/strong><\/h3>\n\n\n\n<p>So even if the PR contains malicious workflow changes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>It cannot push changes<\/li>\n\n\n\n<li>It cannot modify branches<\/li>\n\n\n\n<li>It cannot create tags<\/li>\n\n\n\n<li>It cannot deploy anything<\/li>\n<\/ul>\n\n\n\n<p>This is why <code>pull_request<\/code> is safer than <code>pull_request_target<\/code> (explained in detail in the linked blog).<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\"><strong>4. When Permissions Become Dangerous<\/strong><\/h1>\n\n\n\n<p>The real trouble begins when combining:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>pull_request_target<\/code><\/li>\n\n\n\n<li>With high permissions<\/li>\n\n\n\n<li>With <code>actions\/checkout<\/code> checking out the attacker\u2019s branch<\/li>\n<\/ul>\n\n\n\n<p>This exposes your environment to attacks because:<\/p>\n\n\n\n<p><strong><code>pull_request_target<\/code> runs in privileged context and does not downgrade permissions.<\/strong><\/p>\n\n\n\n<p>If your workflow contains:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>on: pull_request_target\n\npermissions: write-all\n\nsteps:\n  - uses: actions\/checkout@v4\n    with:\n      ref: ${{ github.event.pull_request.head.sha }}\n\n  - run: .\/build.sh\n<\/code><\/pre>\n\n\n\n<p>You have essentially given:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Write access<\/li>\n\n\n\n<li>Token permissions<\/li>\n\n\n\n<li>Ability to run arbitrary attacker code<\/li>\n<\/ul>\n\n\n\n<p>This is why attackers target such workflows.<\/p>\n\n\n\n<p>I have explained this attack path in detail here:<br><strong><a href=\"https:\/\/shreyapohekar.com\/blogs\/how-attackers-exploit-pull_request_target-secure-your-github-ci-cd-workflows\/\">How Attackers Exploit pull_request_target<\/a><\/strong>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\"><strong>5. Example: Safe vs Unsafe Permissions<\/strong><\/h1>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Unsafe Example<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>on: pull_request_target\npermissions: write-all\n\nsteps:\n  - uses: actions\/checkout@v4\n    with:\n      ref: ${{ github.event.pull_request.head.sha }}\n\n  - run: npm install\n  - run: npm run build\n<\/code><\/pre>\n\n\n\n<p>Why unsafe?<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Untrusted code gets executed with write permissions.<\/li>\n\n\n\n<li>PR code can steal secrets or modify the repo.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Safe Example<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>on: pull_request\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - run: npm test\n<\/code><\/pre>\n\n\n\n<p>Why safe?<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The PR code runs in a downgraded environment.<\/li>\n\n\n\n<li>No secrets.<\/li>\n\n\n\n<li>Token is read-only.<\/li>\n\n\n\n<li>Cannot modify the repo even if compromised.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\"><strong>6. Best Practices for Handling Permissions Safely<\/strong><\/h1>\n\n\n\n<h3 class=\"wp-block-heading\">1. <strong>Start with <code>contents: read<\/code><\/strong><\/h3>\n\n\n\n<p>In most cases, workflows need only read access.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. <strong>Grant write permissions only to specific jobs<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>jobs:\n  release:\n    permissions:\n      contents: write\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">3. <strong>Never give write permissions in <code>pull_request_target<\/code><\/strong><\/h3>\n\n\n\n<p>If you truly need metadata updates, keep them read-only or strictly scoped.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. <strong>Avoid using <code>write-all<\/code> unless absolutely necessary<\/strong><\/h3>\n\n\n\n<p>Always restrict to the minimum.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">5. <strong>Avoid running untrusted code in privileged workflows<\/strong><\/h3>\n\n\n\n<p>This is the core principle behind all supply-chain security.<\/p>\n\n\n\n<p>Here\u2019s a concluding paragraph you can add to the permissions blog:<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Managing permissions in GitHub Actions doesn\u2019t 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 <code>pull_request<\/code> 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 <code>pull_request<\/code> 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\u2014knowing what to trust, when, and why.<\/p>\n\n\n\n<p>Hope you enjoyed reading this. See you in the next one. Until then, happy hunting.<\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>GitHub Actions permissions can make or break the security of your CI\/CD pipeline. This blog explains how to apply least-privilege principles, why default write permissions in `pull_request` workflows are still safe, and how thoughtful permission design protects you from common supply-chain risks. It also includes simple examples to help you understand what to allow, what to restrict, and how to keep your workflows secure without slowing development.<\/p>\n","protected":false},"author":1,"featured_media":1890,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"ocean_post_layout":"","ocean_both_sidebars_style":"","ocean_both_sidebars_content_width":0,"ocean_both_sidebars_sidebars_width":0,"ocean_sidebar":"","ocean_second_sidebar":"","ocean_disable_margins":"enable","ocean_add_body_class":"","ocean_shortcode_before_top_bar":"","ocean_shortcode_after_top_bar":"","ocean_shortcode_before_header":"","ocean_shortcode_after_header":"","ocean_has_shortcode":"","ocean_shortcode_after_title":"","ocean_shortcode_before_footer_widgets":"","ocean_shortcode_after_footer_widgets":"","ocean_shortcode_before_footer_bottom":"","ocean_shortcode_after_footer_bottom":"","ocean_display_top_bar":"default","ocean_display_header":"default","ocean_header_style":"","ocean_center_header_left_menu":"","ocean_custom_header_template":"","ocean_custom_logo":0,"ocean_custom_retina_logo":0,"ocean_custom_logo_max_width":0,"ocean_custom_logo_tablet_max_width":0,"ocean_custom_logo_mobile_max_width":0,"ocean_custom_logo_max_height":0,"ocean_custom_logo_tablet_max_height":0,"ocean_custom_logo_mobile_max_height":0,"ocean_header_custom_menu":"","ocean_menu_typo_font_family":"","ocean_menu_typo_font_subset":"","ocean_menu_typo_font_size":0,"ocean_menu_typo_font_size_tablet":0,"ocean_menu_typo_font_size_mobile":0,"ocean_menu_typo_font_size_unit":"px","ocean_menu_typo_font_weight":"","ocean_menu_typo_font_weight_tablet":"","ocean_menu_typo_font_weight_mobile":"","ocean_menu_typo_transform":"","ocean_menu_typo_transform_tablet":"","ocean_menu_typo_transform_mobile":"","ocean_menu_typo_line_height":0,"ocean_menu_typo_line_height_tablet":0,"ocean_menu_typo_line_height_mobile":0,"ocean_menu_typo_line_height_unit":"","ocean_menu_typo_spacing":0,"ocean_menu_typo_spacing_tablet":0,"ocean_menu_typo_spacing_mobile":0,"ocean_menu_typo_spacing_unit":"","ocean_menu_link_color":"","ocean_menu_link_color_hover":"","ocean_menu_link_color_active":"","ocean_menu_link_background":"","ocean_menu_link_hover_background":"","ocean_menu_link_active_background":"","ocean_menu_social_links_bg":"","ocean_menu_social_hover_links_bg":"","ocean_menu_social_links_color":"","ocean_menu_social_hover_links_color":"","ocean_disable_title":"default","ocean_disable_heading":"default","ocean_post_title":"","ocean_post_subheading":"","ocean_post_title_style":"","ocean_post_title_background_color":"","ocean_post_title_background":0,"ocean_post_title_bg_image_position":"","ocean_post_title_bg_image_attachment":"","ocean_post_title_bg_image_repeat":"","ocean_post_title_bg_image_size":"","ocean_post_title_height":0,"ocean_post_title_bg_overlay":0.5,"ocean_post_title_bg_overlay_color":"","ocean_disable_breadcrumbs":"default","ocean_breadcrumbs_color":"","ocean_breadcrumbs_separator_color":"","ocean_breadcrumbs_links_color":"","ocean_breadcrumbs_links_hover_color":"","ocean_display_footer_widgets":"default","ocean_display_footer_bottom":"default","ocean_custom_footer_template":"","ocean_post_oembed":"","ocean_post_self_hosted_media":"","ocean_post_video_embed":"","ocean_link_format":"","ocean_link_format_target":"self","ocean_quote_format":"","ocean_quote_format_link":"post","ocean_gallery_link_images":"on","ocean_gallery_id":[],"footnotes":""},"categories":[1],"tags":[],"class_list":["post-1889","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","entry","has-media"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/posts\/1889"}],"collection":[{"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/comments?post=1889"}],"version-history":[{"count":1,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/posts\/1889\/revisions"}],"predecessor-version":[{"id":1891,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/posts\/1889\/revisions\/1891"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/media\/1890"}],"wp:attachment":[{"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/media?parent=1889"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/categories?post=1889"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/tags?post=1889"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}