Every GitHub Actions workflow runs with an automatically provisioned token that controls what the workflow is allowed to do inside your repository. Understanding how that token works — and how to restrict it — is one of the most important security practices in CI/CD.
Contents
- The GITHUB_TOKEN
- Default Permissions
- The
permissions: Keyword - Workflow-Level vs Job-Level Permissions
- Passing the Token to Actions
- Passing the Token to REST API Calls
- When to Use Personal Access Tokens
- Key Takeaways
- Complete Working Example
The GITHUB_TOKEN
When a workflow run starts, GitHub automatically creates a short-lived installation access token and stores it as a secret named GITHUB_TOKEN. You do not set it up — it appears on its own, every time.
Scope
The token is scoped to the current repository only. Even if the workflow makes REST API calls, the token cannot read or write data in any other repository.
Lifetime
The token expires when the workflow job finishes, or after one hour — whichever comes first. You cannot cache or reuse it across jobs.
How to reference it
# In a workflow step
${{ secrets.GITHUB_TOKEN }}
# Alternative built-in expression (identical value)
${{ github.token }}
Because the token is time-limited and repository-scoped, it is safer than a long-lived personal access token for most automation tasks.
Default Permissions
Repository administrators can choose between two default permission sets for the GITHUB_TOKEN. The setting lives under Settings → Actions → General → Workflow permissions.
GITHUB_TOKEN permission scopes and their defaults
| Scope | Permissive default | Restrictive default |
|---|
| actions | read/write | none |
| checks | read/write | none |
| contents | read/write | read |
| deployments | read/write | none |
| id-token | none | none |
| issues | read/write | none |
| metadata | read | read |
| packages | read/write | read |
| pages | read/write | none |
| pull-requests | read/write | none |
| repository-projects | read/write | none |
| security-events | read/write | none |
| statuses | read/write | none |
The permissive preset is convenient but grants broad write access — a compromised workflow step could push code, close issues, or publish packages automatically. The restrictive preset forces each workflow to declare exactly what it needs, which is the safer posture.
The permissions: Keyword
Regardless of the repository default, you can declare explicit permissions directly in the workflow file. This lets the repository run with permissive defaults while individual workflows opt in to a tighter policy.
Grant one scope, deny everything else
permissions:
pull-requests: write # only permission granted
# every other scope is set to none automatically
Grant several scopes
permissions:
contents: read
statuses: write
issues: write
Explicitly deny all scopes
permissions: {} # empty map — no access at all
Allow all scopes (use with caution)
permissions: read-all # or write-all for full read/write
Valid values for each scope are read, write, and none. Omitting a scope when any other scope is listed is equivalent to setting it to none.
Workflow-Level vs Job-Level Permissions
The permissions: block can appear at two places in a workflow file, and they interact in a specific way:
Workflow level (top of file)
Sets the baseline for all jobs. Jobs that do not declare their own permissions: block inherit this baseline exactly.
Job level (inside a job definition)
Overrides the workflow baseline for that one job only. A job-level block can both add and remove permissions relative to the workflow baseline.
permissions:
contents: read # workflow baseline — all jobs start here
jobs:
lint:
runs-on: ubuntu-latest
# No permissions block — inherits contents: read from above.
steps:
- uses: actions/checkout@v4
- run: npm run lint
open-issue:
runs-on: ubuntu-latest
# Job-level block. Adds issues: write; loses nothing already granted.
permissions:
contents: read
issues: write
steps:
- run: echo "Opening an issue via the API"
A job-level permissions: block replaces the workflow-level block for that job — it does not merge with it. Always re-list every scope you need, including those that were in the workflow baseline.
Passing the Token to Actions
Many community actions accept the token as an input parameter so they can authenticate API calls on your behalf. The parameter is often called repo-token or token:
- name: Apply labels to pull request
uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }} # token is passed explicitly
# The action uses this token to read PR file changes and attach labels.
Passing the token explicitly (rather than expecting the action to find it automatically) makes the token flow visible during code review and security audits. Some actions require it as a required input; others fall back to github.token if no value is supplied.
Passing the Token to REST API Calls
When a workflow step makes HTTP requests to the GitHub REST API directly — for example, to post a commit status or create an issue — it must include the token in the Authorization header:
- name: Post a commit status
run: |
curl -s -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}" \
-d '{
"state": "success",
"context": "documentation-check",
"description": "All public functions have JSDoc blocks"
}'
The Bearer prefix is required. GitHub will reject requests that use the older token <value> format for modern API endpoints.
When to Use Personal Access Tokens
The GITHUB_TOKEN covers the majority of automation use cases. There are, however, situations where it is not sufficient:
- Cross-repository access — triggering a workflow in a different repository, or reading private packages from an organisation's package registry.
- Elevated organisation permissions — managing teams, organisation webhooks, or billing settings.
- Fine-grained PATs — when you need permissions that map exactly to a non-standard scope not covered by GITHUB_TOKEN.
When a personal access token (PAT) is necessary, store it as a repository or organisation secret and reference it like any other secret:
- name: Push changelog to a separate docs repository
env:
DOCS_PAT: ${{ secrets.DOCS_REPO_TOKEN }} # a PAT stored as a secret
run: |
git remote add docs https://x-access-token:${DOCS_PAT}@github.com/my-org/docs-site.git
git push docs HEAD:main
Prefer the GITHUB_TOKEN over a PAT whenever the task fits within the current repository. PATs are harder to rotate, harder to audit, and grant access beyond the workflow's operational context.
Key Takeaways
- GitHub automatically provisions a GITHUB_TOKEN for every workflow run. It is repository-scoped and expires when the job ends.
- Repository administrators choose between permissive and restrictive defaults. Restrictive is the safer starting point.
- Use the
permissions: keyword to declare exactly what each workflow — or each job — needs. This follows the principle of least privilege. - A job-level
permissions: block replaces the workflow-level block for that job; it does not add to it. Re-list every scope you need. - Pass the token to actions via an input parameter (
repo-token:) and to REST API calls via the Authorization: Bearer <token> header. - Use a personal access token only when tasks require cross-repository access or organisation-level permissions that the GITHUB_TOKEN cannot provide.
Complete Working Example
The workflow below demonstrates workflow-level and job-level permission blocks, passing the token to an action, and passing it to REST API calls. It targets pull requests against main and runs three jobs with narrowly scoped permissions.
name: Workflow Permissions - Documentation and Release Pipeline
# Trigger on every pull request so that labels and checks are applied
# automatically, keeping maintainers from having to do this by hand.
on:
pull_request:
branches:
- main
# Workflow-level permission block: start with the most restrictive baseline
# so that each job only holds the rights it actually needs. This limits the
# blast radius if the GITHUB_TOKEN were somehow misused.
permissions:
contents: read # allow checkout; deny any write to the repository tree
jobs:
# ---------------------------------------------------------------
# label: Reads pull request metadata and applies labels based on
# which paths were changed. Needs pull-requests write so it can
# attach the label to the PR; all other scopes stay at their
# workflow-level defaults (contents: read, everything else: none).
# ---------------------------------------------------------------
label:
name: Auto-label Pull Request
runs-on: ubuntu-latest
# Job-level override: grant only the additional scope this job requires.
# pull-requests: write lets the job add/remove labels.
# contents: read is inherited from the workflow-level block above.
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout source
uses: actions/checkout@v4 # needs contents: read -- already granted above
# actions/labeler reads changed file paths and matches them against
# .github/labeler.yml rules. It requires the token to attach labels.
# Passing secrets.GITHUB_TOKEN (not github.token) makes the token
# source explicit and easier to audit during security reviews.
- name: Apply path-based labels
uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# ---------------------------------------------------------------
# check-docs: Verifies that every public function has a JSDoc block.
# Uses the GITHUB_TOKEN via the REST API to post a commit status so
# the result appears on the PR checks panel.
# ---------------------------------------------------------------
check-docs:
name: Verify Documentation Coverage
runs-on: ubuntu-latest
# statuses: write is needed to post the commit check result.
# contents: read is inherited from workflow level.
permissions:
statuses: write
contents: read
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install tools
# Install the doc-coverage linter only -- avoid installing the full
# application dependencies to keep this job fast.
run: npm install --no-save jsdoc-coverage-checker
- name: Run documentation check
id: doc_check
# Run the checker and capture whether it passed so we can use the
# result to set the correct commit status in the next step.
run: |
if npx jsdoc-coverage-checker --min-coverage 80; then
echo "status=success" >> "$GITHUB_OUTPUT"
echo "description=Documentation coverage is above 80%" >> "$GITHUB_OUTPUT"
else
echo "status=failure" >> "$GITHUB_OUTPUT"
echo "description=Documentation coverage is below 80%" >> "$GITHUB_OUTPUT"
fi
# Post the result as a commit status via the GitHub REST API.
# The Authorization header carries the GITHUB_TOKEN -- this is the
# standard way to authenticate API calls from within a workflow step.
- name: Post commit status
run: |
curl -s -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}" \
-d "{
\"state\": \"${{ steps.doc_check.outputs.status }}\",
\"context\": \"doc-coverage\",
\"description\": \"${{ steps.doc_check.outputs.description }}\",
\"target_url\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"
}"
# ---------------------------------------------------------------
# release-notes: Runs only when the PR is merged (closed + merged).
# Opens an issue summarising what changed so stakeholders without
# GitHub access can be notified via the issue email integration.
# Needs issues: write -- it must create a new issue.
# ---------------------------------------------------------------
release-notes:
name: Draft Release Notes Issue
runs-on: ubuntu-latest
# Only execute after the PR is actually merged, not just closed.
if: github.event.pull_request.merged == true
# issues: write is the only extra permission this job needs.
# contents: read is inherited from the workflow-level block.
permissions:
issues: write
contents: read
steps:
- name: Checkout source
uses: actions/checkout@v4
# Create a release-notes issue via the REST API using the GITHUB_TOKEN.
# The token's scope is limited to this repository -- it cannot create
# issues in other repositories even if the workflow were compromised.
- name: Create release notes issue
run: |
TITLE="Release notes: PR #${{ github.event.pull_request.number }} merged"
BODY="**Merged by:** ${{ github.actor }}\n**Branch:** ${{ github.head_ref }} to main\n**Commit:** ${{ github.sha }}"
curl -s -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/issues" \
-d "{\"title\": \"$TITLE\", \"body\": \"$BODY\", \"labels\": [\"release-notes\"]}"