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 through env:.
  • Three levels: repository, environment (with approvals) and organization.
  • Forks: workflows triggered by pull requests from forks don't get your secrets (except GITHUB_TOKEN with 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:

  1. Open your repository โ†’ Settings
  2. Secrets and variables โ†’ Actions
  3. New repository secret
  4. 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 list

Secret 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

flowchart TB ORG["Organization secrets
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, masked

How Secrets Still Leak

Watch out: masking only hides the exact secret string in logs. Transformed values (base64-encoded, split, or part of a JSON blob) may print unmasked.
  • Don't echo secrets, 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: for GITHUB_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-1

No 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

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.