GitHub Actions secrets allow you to store sensitive data — API keys, passwords, tokens, certificates — securely in your repository settings and use them in workflows without exposing them in your code. This guide covers everything from basic secret usage to advanced patterns like OIDC federation, environment-scoped secrets, and automated rotation.
Quick Answers: GitHub Actions Secrets, GITHUB_TOKEN, and gh secret set
Use this short decision guide when you need to configure a secret quickly and safely.
- Repository secrets are available only to workflows in one repository. Create them in Settings > Secrets and variables > Actions, or with gh secret set API_TOKEN.
- Environment secrets are tied to a named environment such as production. A job must select that environment before those secrets are available, and environment protection rules can require reviewers or branch restrictions before release jobs run.
- Organization secrets can be shared across repositories, but organization owners should limit access to selected repositories whenever the credential is not truly global.
- GITHUB_TOKEN is created automatically for each workflow run. You can reference it as secrets.GITHUB_TOKEN, but actions can also access github.token, so always set the minimum permissions block your workflow needs.
- Secrets cannot be referenced directly in if: conditionals. If a secret is unset, the secrets context returns an empty string; move a boolean or the value into env before testing it.
- Secrets other than GITHUB_TOKEN are not passed to workflows triggered from forked repositories. Treat pull_request_target workflows as privileged and never run untrusted fork code with secrets.
# Create or update a repository secret
gh secret set API_TOKEN --body "$API_TOKEN"
# Create an environment-scoped secret for production deploys
gh secret set DATABASE_URL --env production --body "$DATABASE_URL"
# Create an organization secret and limit it to selected repositories
gh secret set SENTRY_AUTH_TOKEN \
--org ORG_NAME \
--repos web-app,api-service \
--body "$SENTRY_AUTH_TOKEN"
# List configured secret names without revealing values
gh secret list
gh secret list --env production
gh secret list --org ORG_NAME# Use the automatic GITHUB_TOKEN with least privilege
permissions:
contents: read
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'CI completed.'
})# Secrets cannot be referenced directly in if:
# Move the value into an env var, then test the env var.
jobs:
deploy:
runs-on: ubuntu-latest
env:
HAS_DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN != '' }}
steps:
- name: Deploy only when the secret exists
if: env.HAS_DEPLOY_TOKEN == 'true'
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy.shBasic Secret Usage
Secrets are defined in your repository settings (Settings > Secrets and variables > Actions) and referenced in workflows using the secrets context. They are automatically masked in workflow logs.
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to server
env:
# Reference a repository secret
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_TOKEN: ${{ secrets.API_TOKEN }}
run: |
echo "Deploying with secure credentials..."
./deploy.shEnvironment-Scoped Secrets
GitHub Environments let you define secrets that are specific to a deployment environment (staging, production) and add protection rules like required reviewers.
# Environment-specific secrets (staging vs production)
name: Deploy
on:
push:
branches: [main, staging]
jobs:
deploy:
runs-on: ubuntu-latest
# Pick environment based on branch
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
# These come from the selected environment's secrets
DATABASE_URL: ${{ secrets.DATABASE_URL }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./deploy.sh --env ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}OIDC Federation: No Long-Lived Secrets
The best practice in 2026 is to use OIDC (OpenID Connect) federation to authenticate with cloud providers instead of storing long-lived access keys. GitHub Actions can exchange a JWT token for temporary cloud credentials.
# OIDC: No long-lived credentials needed!
name: Deploy to AWS (OIDC)
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-east-1
# No ACCESS_KEY or SECRET_KEY secrets needed!
- name: Deploy to S3
run: aws s3 sync ./dist s3://my-bucket --deleteSecret Masking and Safe Usage
GitHub automatically masks secret values in logs, but there are important caveats to understand to avoid accidental exposure.
# Secrets are automatically masked in logs
# But be careful with derived values!
jobs:
careful:
runs-on: ubuntu-latest
steps:
- name: Use secret safely
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
run: |
# This output will be masked: ***
echo "Secret value: $MY_SECRET"
# DANGER: This may expose the secret!
# If MY_SECRET="abc", base64 gives "YWJj" which is NOT masked
echo $MY_SECRET | base64
# Safe: always use secrets directly, not derived forms
curl -H "Authorization: Bearer $MY_SECRET" https://api.example.com/endpoint
- name: Add to mask (for dynamic secrets)
run: |
# Manually mask a dynamically generated value
TOKEN=$(generate-token.sh)
echo "::add-mask::${TOKEN}"
echo "TOKEN=${TOKEN}" >> $GITHUB_ENVSecrets in Reusable Workflows
When using reusable workflows (workflow_call), secrets must be passed explicitly with a secrets map, or intentionally forwarded with secrets: inherit. Do not assume a called workflow receives credentials unless the caller passes them.
# .github/workflows/reusable-deploy.yml
name: Reusable Deploy Workflow
on:
workflow_call:
secrets:
DEPLOY_KEY:
required: true
DATABASE_URL:
required: true
SLACK_WEBHOOK:
required: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: ./deploy.sh
- name: Notify Slack
if: ${{ secrets.SLACK_WEBHOOK != '' }}
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"Deployment complete!"}' \
${{ secrets.SLACK_WEBHOOK }}
# .github/workflows/production.yml — caller
name: Production Deploy
on:
push:
branches: [main]
jobs:
deploy:
uses: ./.github/workflows/reusable-deploy.yml
secrets:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
# Alternative for trusted same-organization workflows:
# deploy:
# uses: org/reusable/.github/workflows/deploy.yml@main
# secrets: inheritAutomated Secret Rotation
Long-lived secrets are a security risk. Set up automated rotation using scheduled workflows.
# Automated secret rotation pattern
name: Rotate API Token
on:
schedule:
- cron: '0 0 1 * *' # Monthly rotation
workflow_dispatch: # Manual trigger
jobs:
rotate:
runs-on: ubuntu-latest
steps:
- name: Generate new token
id: generate
env:
OLD_TOKEN: ${{ secrets.API_TOKEN }}
run: |
# Call API to generate new token using old token
NEW_TOKEN=$(curl -s -X POST \
-H "Authorization: Bearer $OLD_TOKEN" \
https://api.example.com/tokens/rotate | jq -r '.token')
echo "::add-mask::${NEW_TOKEN}"
echo "new_token=${NEW_TOKEN}" >> $GITHUB_OUTPUT
- name: Update GitHub secret
uses: gliech/create-github-secret-action@v1
with:
name: API_TOKEN
value: ${{ steps.generate.outputs.new_token }}
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}Secret Storage Options Comparison
| Option | Scope | Visibility | Rotation | Cost | Best For |
|---|---|---|---|---|---|
| GITHUB_TOKEN | Current workflow run | Automatic | Per run | Free | GitHub API automation |
| Repository Secrets | Single repo | Repo admins | Manual | Free | Simple projects |
| Environment Secrets | Single repo + env | Repo admins | Manual | Free | Multi-stage deploys |
| Organization Secrets | Multiple repos | Org admins | Manual | Free | Shared credentials |
| OIDC Federation | Single repo | No stored secret | Automatic | Free | Cloud deployments |
| HashiCorp Vault | Any | Configurable | Automatic | Paid | Enterprise |
Security Best Practices
- Use OIDC federation for cloud providers (AWS, GCP, Azure) instead of static access keys whenever possible.
- Scope secrets to environments. Production secrets should require environment protection rules (required reviewers, deployment branches).
- Never print secrets in workflow steps, even for debugging. GitHub masks the exact string, but encoding/transformations bypass masking.
- Rotate secrets regularly. Set calendar reminders or use automated rotation workflows for API keys.
- Use the minimum required permissions. Create service accounts with narrow scopes specifically for GitHub Actions.
- Audit secret access via GitHub's audit log. Review which workflows are using which secrets.
Frequently Asked Questions
Can forked repositories access secrets?
No. By default, secrets are not passed to workflows triggered by pull requests from forks. This prevents forked repos from exfiltrating your secrets. For public repos, you can configure which events allow secrets for external contributors, but this requires explicit approval.
What is the difference between repository secrets and organization secrets?
Repository secrets are only available to workflows in that specific repository. Organization secrets can be shared across multiple repositories and have a policy setting that controls which repos can access them (all repos, selected repos, or only private repos).
How many secrets can I store?
GitHub allows up to 100 secrets per repository, 100 secrets per environment, and 1,000 secrets per organization. Each secret is limited to 48 KB. If a repository has access to more than 100 organization secrets, only the first 100 organization secrets sorted alphabetically are available to a workflow.
Can I read a secret value after it is stored?
No. Once a secret is created, its value cannot be read through the GitHub UI or API. You can only update or delete it. This is by design — if you lose track of a secret, create a new one and rotate the credentials.
How do I pass secrets between jobs in a workflow?
Secrets cannot be directly passed between jobs as job outputs (outputs are visible in logs). Instead, re-reference the secret in each job that needs it using secrets.MY_SECRET, or generate a temporary credential in one job and pass it through encrypted artifact storage using GPG.
Is secrets.GITHUB_TOKEN the same as GITHUB_TOKEN?
The automatic GITHUB_TOKEN is generated for each workflow run and can be referenced as secrets.GITHUB_TOKEN. Some actions can also access it through the github.token context even if you do not pass it explicitly, so restrict it with a permissions block.
Why is my GitHub Actions secret empty in an if condition?
GitHub Actions does not allow direct secrets references in if: conditionals. An unset secret resolves to an empty string when referenced in expressions. Put the check or value into an environment variable first, then test env.MY_FLAG or env.MY_SECRET in the conditional.