Secrets & Variables
The moment your workflow does more than test — deploys somewhere, publishes a package, calls an API — it needs credentials. And a workflow file is the worst place for a credential: it's in git history forever, visible to everyone with repo access. GitHub's answer is a store outside the repo: secrets for sensitive values, variables for plain configuration.
💡 The Bottom Line
Store credentials in Settings → Secrets and variables → Actions; reference them as
${{ secrets.NAME }}. They're encrypted at rest, never appear in the file, and
are masked in logs (printed values become ***).
Non-sensitive config uses ${{ vars.NAME }} — same mechanics, visible values,
no masking.
Using a secret
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
SITE_URL: ${{ vars.SITE_URL }}
run: ./deploy.sh
The pattern to copy: pass the secret into the step as an env: variable, and
let the script read $DEPLOY_TOKEN from the environment. Avoid splicing
${{ secrets.X }} directly into run: command text — inlined values
can leak through shell tracing and error messages in ways environment variables don't.
Scopes: repository, environment, organization
- Repository secrets/variables — available to all workflows in the repo. The default place to start.
- Environment secrets — attached to a named environment
(
staging,production); a job opts in withenvironment: productionand only then can read them. Environments also carry protection rules — required human approval, branch restrictions — so "production deploys need sign-off from the release team and can only run from main" is configuration, not custom code. This is the CI/CD track's promotion-with-approval, natively. - Organization secrets — shared across many repos (one API key, thirty repos), managed centrally.
The token you get for free
Every run receives secrets.GITHUB_TOKEN automatically — a short-lived
credential for the GitHub API, scoped to this repo, expiring when the job ends. It's how
workflows push commits, comment on PRs, or publish releases without you creating anything.
Its default permissions are set repo-wide, and a workflow can (and should) narrow them with a
permissions: block — least privilege, one YAML key.
🙋 There Are No Dumb Questions
Q: Can I look at a secret's value after saving it?
A: No — write-only by design. You can overwrite or delete it, never view it. So keep the
canonical copy in a real password manager, and treat GitHub as a deployment target for the
value, not its home. (And if you're thinking run: echo $MY_SECRET — masking
turns it into ***, though clever encodings can defeat masking, which is why
the next box exists.)
⚠️ Watch Out
Masking is a safety net, not a guarantee: a secret that's transformed (base64, split across lines, URL-encoded) won't match the masker's pattern and prints in the clear. And logs are visible to everyone who can see the repo — public means public. If a secret ever appears in a log, treat it as burned: rotate the credential, then fix the step that printed it. The CI/CD track's rule applies verbatim: rotate, don't just delete.
🔧 Try It Yourself
Watch masking work. In your scratch repo: Settings → Secrets and variables → Actions →
New repository secret, name TEST_SECRET, any value. Then:
on: workflow_dispatch
jobs:
demo:
runs-on: ubuntu-latest
steps:
- env:
S: ${{ secrets.TEST_SECRET }}
run: |
echo "The secret is: $S"
echo "Length: ${#S}"
Run it and read the log: the value prints as ***, while the length prints
fine — masked, but real, and genuinely available to your script.
✅ Quick Check
What's the difference between a secret and a variable in GitHub Actions?
Next up: assembling everything so far — checkout, setup, cache, lint, matrix tests, build, artifact — into a real CI pipeline.