Understanding GitHub Actions Secrets
GitHub Actions Secrets are encrypted variables that allow you to store sensitive information—such as API keys, access tokens, private SSH keys, database connection strings, and signing certificates—securely within your repository or organization. Rather than hard-coding credentials into workflow files where they would be exposed in plain text, secrets let you reference these values safely through the ${{ secrets.SECRET_NAME }} syntax.
Secrets are encrypted at rest and only decrypted for use within the Actions runtime. Once set, their values are never visible again through the GitHub UI or API, and they are automatically redacted from log output. This makes them the foundational security mechanism for any workflow that needs to interact with external services or deploy to production environments.
Where Secrets Can Be Scoped
GitHub offers three levels of secret scoping, each serving a different purpose:
- Repository-level secrets — Scoped to a single repository and available only to workflows within that repo. Ideal for project-specific credentials.
- Organization-level secrets — Scoped to an entire organization and accessible across multiple repositories based on access policies. Perfect for shared infrastructure keys or third-party service tokens used across teams.
- Environment-level secrets — Scoped to a specific deployment environment within a repository. These secrets are only available to jobs that explicitly target that environment, enabling strict separation between staging and production credentials.
Why Secrets Matter
Without secrets management, developers often resort to dangerous patterns: committing credentials to source control, storing them in unencrypted files, or passing them as plain text build arguments. Each of these practices creates a significant risk of credential leakage. Even a single exposed API key can lead to cloud infrastructure compromise, data breaches, or unauthorized access to production systems. Secrets enforce the principle of least privilege by ensuring credentials are only available to the workflows that explicitly need them, and only for the duration of the job execution.
Understanding Environments
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Environments in GitHub Actions represent deployment targets such as staging, production, or preview. They provide a governance layer on top of workflows, allowing you to define protection rules, required reviewers, wait timers, and environment-specific secrets and variables. Environments are configured at the repository level under the Settings → Environments menu and are referenced in workflow files using the environment key on a job definition.
When a job references an environment, GitHub evaluates the environment's protection rules before allowing the job to proceed. This creates a powerful deployment gate that prevents unauthorized or untested code from reaching critical infrastructure.
Environment Protection Rules
Each environment can be configured with one or more of the following protection rules:
- Required reviewers — Up to 50 individuals or teams must approve the deployment before the job can run. This creates a manual approval checkpoint.
- Wait timer — A countdown (up to 30 days) that delays job execution after initial trigger. Useful for scheduled maintenance windows.
- Deployment branches — Restricts which branches can deploy to the environment. For example, only the
mainbranch may target theproductionenvironment. - Custom deployment protection rules — Webhook-based integrations that allow third-party systems (like service management tools or custom approval dashboards) to approve or reject deployments.
Environments also support their own set of secrets and variables, completely isolated from repository-level secrets. A production environment can have a PROD_DATABASE_URL secret that is entirely inaccessible to staging workflows, eliminating the risk of cross-environment credential leakage.
Creating and Configuring Secrets
To create a repository-level secret, navigate to your repository on GitHub, go to Settings → Secrets and variables → Actions, and click New repository secret. Give the secret a name (uppercase and underscores are conventional, e.g., AWS_ACCESS_KEY_ID) and paste its value. The same process applies at the organization level through the organization settings.
For environment-level secrets, first create an environment under Settings → Environments, then add secrets within that environment's configuration panel. Secrets defined here are only available to jobs that declare that specific environment.
Using Secrets in Workflow Files
Once defined, secrets are accessed through the secrets context in your YAML workflow file. Here is a basic example that uses a secret to authenticate with a cloud provider:
name: Deploy to AWS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
run: |
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
aws configure set region us-east-1
- name: Deploy to S3
run: aws s3 sync ./build/ s3://my-production-bucket --delete
Notice that secrets are never printed or logged. GitHub Actions automatically masks secret values in log output, replacing them with ***. However, you should still avoid intentionally echoing secrets or including them in debug output, as this could circumvent the masking in edge cases.
Passing Secrets to External Actions
Many community and marketplace actions accept secrets as input parameters. When using these actions, always pass secrets explicitly rather than relying on environment variables that might leak:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to npm
uses: JS-DevTools/npm-publish@v3
with:
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org/
Here, the NPM_TOKEN secret is passed directly as an input parameter to the action. The action's internal code handles the token securely. Never concatenate secrets into shell commands that could appear in process listings or error messages.
Setting Up Environments in Workflows
To target an environment, add the environment key to your job definition. The value should match the environment name you configured in repository settings. You can optionally specify a URL that will appear in the deployment status on GitHub:
jobs:
deploy-to-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run database migrations
run: npm run migrate
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
- name: Deploy application
run: npm run deploy:production
env:
API_KEY: ${{ secrets.PROD_API_KEY }}
In this example, the job named deploy-to-production targets the production environment. The secrets PROD_DATABASE_URL and PROD_API_KEY must be defined as environment-level secrets within the production environment configuration. Repository-level secrets with the same names will not be accessible here—the environment scope takes precedence.
Multi-Environment Pipeline Example
A common pattern is to promote deployments through a series of environments, such as development → staging → production. Each environment can have its own protection rules and secrets, creating a secure promotion pipeline:
name: Multi-Environment Deployment
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build artifact
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
- name: Deploy to staging
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
- name: Deploy to production
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}
This pipeline demonstrates several important concepts. The build job runs once and uploads an artifact. The deploy-staging job picks up that artifact and deploys to the staging environment. Only after staging succeeds does deploy-production run, targeting the production environment. If the production environment has required reviewers configured, the workflow will pause at this stage until the designated approvers sign off, providing a manual gate before production deployment.
Environment Variables vs. Secrets vs. Configuration Variables
GitHub Actions distinguishes between three types of workflow values, and understanding the difference is critical for security:
- Secrets — Encrypted values for sensitive data. Redacted in logs. Accessed via
${{ secrets.NAME }}. Examples: API keys, tokens, private keys. - Variables — Unencrypted configuration values. Visible in logs and workflows. Accessed via
${{ vars.NAME }}. Examples: environment names, feature flags, non-sensitive URLs. - Environment variables — Set at the step or job level using the
envkey. These are plain text and visible in logs. They can reference secrets or variables for composition.
Never store sensitive values in variables or hard-coded environment variables. The following example shows proper separation:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
env:
# Non-sensitive configuration — safe to expose in logs
APP_ENV: ${{ vars.APP_ENV }}
LOG_LEVEL: debug
# Sensitive credential — passed via secret
DATABASE_PASSWORD: ${{ secrets.DB_PASSWORD }}
steps:
- name: Display configuration
run: |
echo "Deploying to environment: $APP_ENV"
echo "Log level is set to: $LOG_LEVEL"
# Database password is automatically masked in logs
echo "Database password length: ${#DATABASE_PASSWORD}"
Best Practices for Secrets Management
1. Use Environment-Scoped Secrets for Deployment Targets
Always place deployment-specific credentials in environment-level secrets rather than repository-level secrets. This ensures that a staging workflow cannot accidentally access production credentials. Even if a workflow file is misconfigured, the environment scope acts as a hard security boundary.
2. Implement Mandatory Reviewers for Production
For any environment that affects real users or revenue, configure required reviewers. This creates a human-in-the-loop approval step that cannot be bypassed by automated workflows. Combine this with branch protection rules on your main branch for defense in depth.
# Production environment configuration (set in GitHub UI):
# - Required reviewers: @team/infrastructure, @team/security
# - Deployment branches: main
# - Wait timer: 0 minutes (immediate after approval)
3. Rotate Secrets Regularly
Establish a rotation schedule for long-lived secrets such as API keys and access tokens. While GitHub does not yet have built-in secret rotation, you can automate this using scheduled workflows that call your provider's API to generate new credentials and update the corresponding GitHub secret via the GitHub API or CLI.
name: Rotate AWS Access Keys (Weekly)
on:
schedule:
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
jobs:
rotate:
runs-on: ubuntu-latest
steps:
- name: Generate new access key
run: |
NEW_KEY=$(aws iam create-access-key --user-name github-actions-bot | jq -r '.AccessKey')
echo "ACCESS_KEY_ID=$(echo $NEW_KEY | jq -r '.AccessKeyId')" >> $GITHUB_ENV
echo "SECRET_ACCESS_KEY=$(echo $NEW_KEY | jq -r '.SecretAccessKey')" >> $GITHUB_ENV
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ADMIN_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ADMIN_SECRET_KEY }}
- name: Update GitHub secrets
uses: hmanzur/actions-set-secret@v2
with:
name: 'AWS_ACCESS_KEY_ID'
value: ${{ env.ACCESS_KEY_ID }}
token: ${{ secrets.GH_PAT }}
- name: Clean up old key (after propagation delay)
run: |
sleep 300 # Wait 5 minutes for new key to propagate
aws iam delete-access-key --user-name github-actions-bot --access-key-id ${{ secrets.AWS_ACCESS_KEY_ID }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ADMIN_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ADMIN_SECRET_KEY }}
4. Avoid Secrets in Forked Repository Workflows
By default, secrets are not available to workflows triggered by pull requests from forked repositories. This is a critical security control that prevents malicious contributors from exfiltrating secrets through a PR. If your workflow absolutely needs secrets for PR builds (e.g., to run integration tests against a staging environment), use the pull_request_target event with extreme caution, or better yet, use a separate non-sensitive test environment.
5. Use OIDC Instead of Long-Lived Cloud Credentials
For cloud providers that support OpenID Connect (OIDC), such as AWS, Azure, and GCP, prefer OIDC-based authentication over static access keys stored as secrets. OIDC allows GitHub Actions to request short-lived tokens directly from your cloud provider, eliminating the need for long-lived credentials entirely.
name: Deploy to AWS with OIDC
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
aws-region: us-east-1
# No access key or secret key needed!
- name: Deploy to S3
run: aws s3 sync ./dist/ s3://my-production-bucket --delete
This approach is more secure because tokens are ephemeral, automatically expire, and are scoped to the exact IAM role you configure. There are no secrets to rotate or leak.
6. Audit and Monitor Secret Access
Regularly review which secrets exist, where they are used, and whether they are still needed. GitHub provides a security overview dashboard that shows secret scanning alerts. Enable secret scanning push protection to block commits that contain known secret patterns. For organization-level secrets, review the access policy to ensure only intended repositories can access them.
7. Never Reuse Secrets Across Environments
Each environment should have its own distinct set of credentials. A staging database password should never match the production database password. This limits the blast radius if a staging credential is compromised and makes it trivial to rotate credentials for a single environment without affecting others.
8. Treat Workflow Files as Security-Sensitive
Workflow files in .github/workflows/ are code and should be reviewed with the same rigor as application code. A malicious workflow change could exfiltrate secrets by sending them to an attacker-controlled server. Use CODEOWNERS to require review on workflow file changes, especially in repositories with access to high-value secrets.
Debugging and Troubleshooting Secrets
When a secret appears to not be working, several common issues could be at play:
- Scope mismatch — The secret is defined at the repository level but the job targets an environment, or vice versa. Verify the secret exists in the correct scope.
- Naming inconsistency — Secret names are case-sensitive.
API_KEYandapi_keyare different secrets. - Forked repository restrictions — Secrets are not passed to workflows triggered by
pull_requestevents from forks. Usepull_request_targetonly if you fully understand the security implications. - Workflow permissions — The workflow may lack necessary permissions. For OIDC, ensure
id-token: writeis set. For GitHub API operations, setcontents: reador appropriate scopes. - Secret not propagated — After creating or updating a secret, there may be a short delay before it is available in running workflows. New secrets typically propagate within seconds.
A safe way to verify a secret is accessible without revealing its value is to check its length or first few characters in a controlled way:
steps:
- name: Verify secrets are set
run: |
if [ -z "${{ secrets.DATABASE_URL }}" ]; then
echo "ERROR: DATABASE_URL secret is empty or not set"
exit 1
fi
echo "Secret is set and contains ${#DATABASE_URL} characters"
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
This script confirms the secret exists and reports its length without ever printing the actual value.
Conclusion
GitHub Actions Secrets and Environments together form a robust security framework for CI/CD pipelines. Secrets protect sensitive credentials from exposure, while environments provide deployment gates with approval workflows, branch restrictions, and scoped credential isolation. By combining these features—scoping secrets to specific environments, implementing mandatory reviewers for production, favoring OIDC over long-lived tokens, and regularly rotating credentials—you can build deployment pipelines that are both secure and auditable. The key takeaway is to treat every secret as a potential attack vector: minimize its scope, limit its lifespan, monitor its usage, and never let it appear in plain text anywhere in your repository or logs.