Build the mental model
A secret is a value GitHub stores encrypted and injects only into workflow runs that are allowed to see it. You reach it as secrets.NAME, and the sensible pattern is to map it into an env: block on the single step that needs it rather than exposing it workflow-wide. Repository secrets are available to every workflow in the repo. Environment secrets are attached to a named environment and can sit behind a required-reviewer rule, which is how you make production credentials require a human before they are handed out at all.
Masking is the feature people over-trust. Actions scans log output for the literal values of the secrets available to that run and replaces them with asterisks. That protects the obvious accident of echoing a token. It does not follow the value through transformations. Base64 it, URL-encode it, slice it, or embed it in JSON that gets re-serialised, and the masker no longer recognises the string. Treat masking as a safety net for mistakes, not a boundary you can design around.
GITHUB_TOKEN is different. It is minted automatically for each run and expires when the run ends, so it never has to be rotated. Its default scope comes from a repository or organisation setting and is often far broader than the workflow needs. The permissions: block resets that: anything you do not list becomes none, and you can write it at workflow level, per job, or both.
Finally, a pull_request event from a fork runs with a read-only token and no secrets whatsoever. That is not a bug to work around. Without it, anyone could open a pull request whose workflow prints your deploy key.
GITHUB_TOKEN SCOPE, WIDEST TO NARROWEST
---------------------------------------
[1] repo or org default setting (read and write)
contents:w issues:w pull-requests:w packages:w pages:w ...
+---------------------------------------------------------+
[2] workflow-level permissions: contents: read
contents:r every other scope -> none
+--------------+
[3] job-level permissions: contents: read, packages: write,
id-token: write
contents:r packages:w id-token:w all others -> none
+------------------------------+
[4] pull_request from a FORK
contents:r only, and secrets.* resolve to empty strings
+--------+
narrower is safer: a step can only misuse what it was grantedConnect it to a real scenario
Consider a publish workflow triggered by a version tag. It needs exactly two capabilities: read the repository and push a package. Everything else, from writing issues to moving deployments, is attack surface it can do without.
So the workflow sets permissions: contents: read at the top, which makes every job in the file default to read-only, and the publish job adds packages: write and id-token: write on top of that. id-token is what lets npm publish --provenance mint a signed attestation through OIDC; without it the flag fails, and that failure is the honest kind, because the permission genuinely was not granted.
environment: production attaches the job to an environment, so the NPM_TOKEN secret can live there behind a required-reviewer rule rather than sitting in repository secrets where any workflow in the repo could reach it.
The token-presence check before publish exists because the secrets context is not available in a step-level if:. You cannot write if: secrets.NPM_TOKEN != '' and have it evaluate. The workable pattern is to map the secret into env: and test the environment variable in the shell, which turns a confusing mid-publish authentication error into a clear message at the top of the job. It is also exactly what a fork pull request would hit, since every secret resolves to an empty string there.
Try the working example
name: Publish package
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: https://registry.npmjs.org
- name: Fail early when no token is available
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NPM_TOKEN" ]; then
echo "NPM_TOKEN is empty: forked pull request, or the secret was never created."
exit 1
fi
- name: Install dependencies
run: npm ci
- name: Publish with provenance
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The workflow only runs when a tag matching v* is pushed, so a pull request cannot trigger a publish at all. Every job in the file starts with a token limited to reading repository contents; the publish job widens that to include writing packages and requesting an OIDC id-token, and nothing else. Because the job names an environment, the run pauses for approval if that environment has a required reviewer, and only then are the environment's secrets made available to it. The presence check runs before publish, so when NPM_TOKEN resolves to an empty string, which is what happens on a fork or when the secret was never created, the job fails immediately with a readable message instead of failing halfway through publishing. If a token value did appear verbatim in a log line, Actions would replace it with asterisks, but only in that exact form: a transformed or encoded copy of the same value would print in full.5-minute try-it
Add permissions: contents: read to a workflow that currently has no permissions block, and find out which of your steps break. Those breakages are exactly the permissions the workflow was silently relying on. Grant only those back, at job level. Then, in a throwaway private repository, set a secret to a known value and print its base64 encoding in a step. Confirm for yourself that masking does not follow the transformation, then delete the repository.
One important caution
Writing if: secrets.MY_TOKEN != '' on a step: the secrets context is not available in step-level conditions, so map the secret into env: and test the environment variable in the shell instead.
Relying on masking after transforming a secret: base64-encoding, URL-encoding, or slicing a token produces a string the masker does not recognise, and it prints in full.
GitHub Docs - Using secrets in GitHub Actions — CI/CD with GitHub Actions