Why Secrets Matter in CI
Your workflow needs to deploy to Vercel, push to AWS, or call an API. That needs keys. Putting them in your YAML file means anyone who can read the repo can steal them, and Git history keeps them forever.
GitHub Actions secrets store sensitive values encrypted, inject them only when a workflow runs, and mask them in logs. This guide shows how to set them up correctly, and the mistakes that still leak them.
๐ฏ Quick Answer (30-Second Read)
- Add a secret: repo โ Settings โ Secrets and variables โ Actions โ New repository secret.
- Use it:
${{ secrets.MY_API_KEY }}, usually passed throughenv:. - Three levels: repository, environment (with approvals) and organization.
- Forks: workflows triggered by pull requests from forks don't get your secrets (except
GITHUB_TOKENwith read-only permissions). - Better than long-lived keys: use OIDC to get short-lived cloud credentials with no stored secret.
Step 1: Add a Secret
In the browser:
- Open your repository โ Settings
- Secrets and variables โ Actions
- New repository secret
- Name it (e.g.
VERCEL_TOKEN), paste the value, save
With the GitHub CLI:
gh secret set VERCEL_TOKEN # prompts for the value
gh secret set DATABASE_URL < .env.database # from a file
gh secret listSecret names can contain letters, numbers and underscores, can't start with a number, and can't start with GITHUB_.
Step 2: Use It in a Workflow
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: npx vercel deploy --prod --token "$VERCEL_TOKEN"Pass secrets through env: and reference them as shell variables ("$VERCEL_TOKEN") rather than pasting ${{ secrets.X }} directly into a run: command. That keeps values out of the generated script and avoids shell-injection surprises.
Where Secrets Live
shared across chosen repos"] --> RUN REPO["Repository secrets
this repo only"] --> RUN ENV["Environment secrets
e.g. production, with
required reviewers"] --> RUN RUN["Workflow job
${{ secrets.NAME }}"] --> MASK["Values masked as ***
in logs"]
| Level | Use for | Extra protection |
|---|---|---|
| Repository | Keys used by one repo | Standard |
| Environment | Production deploy keys | Required reviewers, wait timers, branch restrictions |
| Organization | Keys shared across many repos | Choose which repos can access |
If the same name exists at several levels, the most specific wins: environment over repository over organization.
Environment example:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # job pauses for approval if reviewers are required
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.PROD_API_KEY }}Secrets vs Variables
Not everything needs to be secret. GitHub also has variables for non-sensitive config:
env:
NODE_ENV: ${{ vars.NODE_ENV }} # visible, editable config
STRIPE_KEY: ${{ secrets.STRIPE_KEY }} # encrypted, maskedHow Secrets Still Leak
- Don't
echosecrets, even for debugging - Register derived values you generate during a run with
echo "::add-mask::$VALUE" - Avoid structured secrets (whole JSON files). Store individual values instead
- Pin third-party actions to a full commit SHA. A compromised action can read your secrets
- Be careful with
pull_request_target: it runs with secrets in the context of your repo, so never check out and run untrusted PR code with it - Set least-privilege
permissions:forGITHUB_TOKEN - Rotate any secret that may have leaked, immediately
Pull Requests From Forks
Secrets (other than GITHUB_TOKEN) are not passed to workflows triggered by pull requests from forked repositories. That protects you from someone opening a PR that prints your keys. If your tests need secrets, run them only on branches in your own repo, or use a separate, carefully reviewed workflow.
Even Better: OIDC Instead of Stored Keys
For AWS, Google Cloud and Azure, you can skip long-lived keys entirely. GitHub issues a short-lived OIDC token to the workflow, and the cloud provider exchanges it for temporary credentials.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: ap-south-1No secret to store, nothing to rotate, and credentials expire automatically.
Frequently Asked Questions
Can I view a GitHub secret after saving it?
No. Once saved, a secret's value can't be viewed in the GitHub interface, only updated or deleted. Keep the original in a password manager or your provider's dashboard.
Why is my secret empty in a workflow?
Common causes: a typo or case mismatch in the name, the workflow being triggered by a pull request from a fork, an environment secret used in a job without the matching environment key, or an organization secret not shared with that repository.
Are GitHub Actions secrets safe?
They're encrypted and masked in logs, which is safe for most use. Risks come from workflow design: printing transformed secrets, running untrusted code with access to secrets, or using unpinned third-party actions. Using OIDC for cloud access removes long-lived secrets altogether.
Conclusion
Store keys as GitHub Actions secrets, pass them through env:, use environments with reviewers for production, and never print or transform them in logs. For cloud deployments, switch to OIDC so there's no long-lived secret to leak at all.
Sources
- GitHub Docs: Using secrets in GitHub Actions
- GitHub Docs: Security hardening for GitHub Actions
- GitHub Docs: OpenID Connect
Last verified: 2 November 2026.
Related reads: CI/CD Explained ยท How to Use Environment Variables in Next.js ยท How to Deploy Next.js on Vercel
I write practical guides for developers. Follow on LinkedIn or X, or invite me to speak.