GitHub Actions Secrets: How to Store and Use Them Safely
GitHub Actions secrets keep API keys, tokens and passwords out of your code while letting workflows use them. How to add repository, environment and organisation secrets, use them in a workflow, secrets vs variables, the fork and pull_request_target pitfalls, and OIDC instead of long-lived keys.
Your GitHub Actions workflows often need credentials: an SSH key to deploy, an API key for tests, a token to publish a package. Secrets let workflows use them without putting them in your repository.
Adding a secret
- In your repository, go to Settings → Secrets and variables → Actions.
- Click New repository secret.
- Give it a name like
DEPLOY_SSH_KEYand paste the value.
Once saved, nobody — including you — can read the value back in the UI. You can only replace or delete it.
From the command line, with the GitHub CLI:
gh secret set DEPLOY_SSH_KEY < ~/.ssh/deploy_key
Using a secret in a workflow
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm test
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
Pass secrets to the steps that need them through env, rather than to the whole job. GitHub masks secret values in logs, replacing them with ***. (GitHub Actions CI basics)
Three levels of secrets
| Level | Where | Use for |
|---|---|---|
| Repository | Settings of one repo | Most things |
| Environment | Settings → Environments | Different values for staging vs production, with optional approval before use |
| Organisation | Org settings | Shared across many repos |
Environment secrets are worth using for production deploys: you can require a reviewer to approve a job before it gets the production credentials, and limit which branches can deploy. (Dev, staging and production)
jobs:
deploy:
environment: production
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Secrets vs variables
Next to secrets is a Variables tab. Use variables for non-sensitive configuration (an API URL, a region) — they're visible and readable as ${{ vars.NAME }}. Use secrets for anything you wouldn't want in a screenshot.
The pitfalls
Masking isn't perfect
Masking only hides the exact value. If a step prints it base64-encoded, split up, or in a URL, it can leak. Never echo a secret, and be careful with debug logging.
Pull requests from forks don't get secrets
Workflows triggered by pull_request from a fork run without your secrets — deliberately, so a stranger's PR can't steal them. That's why tests needing secrets fail on outside contributions.
Be very careful with pull_request_target
pull_request_target does run with secrets, in the context of your repository. If such a workflow checks out and runs the PR's code, an attacker can submit a PR that exfiltrates your secrets. Avoid it unless you understand exactly what runs.
Third-party actions can see what you give them
An action you uses: runs code with access to the secrets you pass. Pin third-party actions to a full commit SHA rather than a moveable tag, and prefer well-known publishers. (npm supply chain security)
Better than secrets: OIDC
For cloud providers (AWS, Google Cloud, Azure) and package registries like npm, you can skip long-lived keys entirely. With OpenID Connect, the workflow requests a short-lived token at run time, and the provider trusts GitHub's identity for that specific repo and branch. Nothing to leak, nothing to rotate.
permissions:
id-token: write
contents: read
If a secret leaks
Rotate it at the source (generate a new key with the provider, revoke the old one), then update the GitHub secret. Deleting the old log isn't enough. (I leaked an API key)
The summary
- Store credentials in Settings → Secrets and variables → Actions.
- Read them with
${{ secrets.NAME }}, passed only to the steps that need them. - Use environment secrets with approvals for production.
- Watch out for forks,
pull_request_targetand third-party actions. - Prefer OIDC over long-lived cloud keys.
EasySpawn deploys from your repository with credentials kept on the server side, so your workflows don't need to carry production database passwords around. See how it works or join the waitlist.
Related: Set Up CI With GitHub Actions · Deploy to a VPS With GitHub Actions · Secrets Management Beyond .env Files · GitHub Secret Scanning
Keep reading
GitHub Secret Scanning and Push Protection: Stop Leaking API Keys
Secret scanning finds API keys, tokens and passwords committed to your repository; push protection blocks them before they're pushed. How both work on GitHub, what to do when one fires, why deleting the commit isn't enough, and local tools like gitleaks and pre-commit hooks.
npm run dev vs npm run build vs npm start: What Each One Does
npm run dev starts a development server with hot reload; npm run build makes an optimised production version; npm start runs that production version. What each does, where the commands come from in package.json, and why you should never run dev mode in production.