Mert Özen Further With Every Line
DevOps GitHub Actions — #08

Secrets, Variables and GITHUB_TOKEN Permissions in GitHub Actions

Mert Özen Nov 8, 2026 10 min 3 views
Secrets, Variables and GITHUB_TOKEN Permissions in GitHub Actions

Secrets are defined at organization, repository and environment level, the narrowest level wins, and they are masked in logs by text matching; derived values such as base64 and fields read out of JSON are not masked. With fake secrets in act we show ::add-mask::, secrets in if conditions, variables and an empty GITHUB_TOKEN, and cover least privilege with permissions, the vulnerability-alerts scope from September 2026, and GitHub App tokens instead of PATs.

Version note: The examples in this post were tested with act 0.2.97, actionlint 1.7.12 and Docker 29.2. The workflows were run locally with act using fake secret values made up for this post only; they were not run on GitHub and no real token was used. The information is current as of November 2026.

Guess First

Bookstore has a private NuGet feed and CI (Continuous Integration) connects to it with Basic authentication. The token is defined as a repository secret named NUGET_FEED_TOKEN. To debug a connection problem, Alan adds two lines:

    env:
      NUGET_FEED_TOKEN: ${{ secrets.NUGET_FEED_TOKEN }}
    steps:
      - name: A secret is masked
        run: echo "token=$NUGET_FEED_TOKEN"

      - name: A value derived from it is not
        run: echo "auth=$(printf 'bookstore:%s' "$NUGET_FEED_TOKEN" | base64)"

GitHub masks secrets in the log; Alan knows that. So which of the two lines gives the secret away in the log: neither, the first, or the second?

$ act workflow_dispatch --secret-file act.secrets --var-file act.vars
[secrets-demo/secrets] ⭐ Run Main A secret is masked
[secrets-demo/secrets]   | token=***
[secrets-demo/secrets] ⭐ Run Main A value derived from it is not
[secrets-demo/secrets]   | auth=Ym9va3N0b3JlOmZha2UtZmVlZC10b2tlbi0wMDAx

The second. Masking searches the log for the secret's value as text and replaces it with asterisks; the value's base64, its URL (Uniform Resource Locator) encoded form or its reverse are different text and are printed as they are. Anyone who decodes the line above from base64 sees bookstore:fake-feed-token-0001 (the fake value we gave act). In a public repository anyone can read that log. In this post we look at where secrets and variables are defined, how masking works and where it stops, at GITHUB_TOKEN, and at narrowing it with permissions:.

Secrets and Variables: Levels and Precedence

GitHub has secrets for sensitive values and variables for non-sensitive configuration. Both can be defined at three levels: organization, repository and environment. If the same name is defined at more than one level, the narrowest wins: environment overrides repository, and repository overrides organization. In a workflow, secrets are read from the secrets context and variables from the vars context:

      - name: Configuration variable
        run: echo "api base url=${{ vars.API_BASE_URL }}"
[secrets-demo/secrets]   | api base url=https://staging.bookstore.example

The difference the documentation warns about explicitly: variables are not masked in the log. A staging URL, a feature flag or a region name is a variable; a password, a token or a connection string is a secret. In act we passed these values with --secret-file and --var-file; the files live in the work folder and never enter the repository.

The documented limits: 100 secrets per repository, 1,000 per organization, 100 per environment; 48 KB per secret. Secret names may contain only letters, digits and underscores, cannot start with GITHUB_, and are case-insensitive. Organization and repository secrets are read when the run is queued, environment secrets when the job that uses that environment starts. Environments and their approval rules are the topic of the ninth post.

The only way to give a secret to an action or a script is to write it explicitly: into an input (with:) or an environment variable (env:). A secret not mentioned in the workflow is never sent to the runner. That is why you put a secret not in the workflow-level env: but in the job or step that really needs it.

Masking and ::add-mask::

To add a derived value to masking before printing it to the log, there is the ::add-mask:: workflow command:

      - name: Mask the derived value first
        run: |
          auth=$(printf 'bookstore:%s' "$NUGET_FEED_TOKEN" | base64)
          echo "::add-mask::$auth"
          echo "auth=$auth"
[secrets-demo/secrets] ⭐ Run Main Mask the derived value first
[secrets-demo/secrets]   | auth=***

Order matters: the ::add-mask:: line must come before the value is first written to the log; lines printed before it are not masked retroactively. Better still, don't print the derived value at all; even when debugging, questions like "is the value empty, how long is it" are usually enough.

Structured secrets are a separate trap. Storing database details as a single JSON (JavaScript Object Notation) secret and reading a field out of it is tempting:

      - name: JSON secret
        run: echo "password=${{ fromJSON(secrets.DB_CONFIG).password }}"
[secrets-demo/secrets]   | password=Fake-Pass-0001

What gets masked is the whole secret, that is, the whole JSON text; the part you extract is a separate piece of text, so act printed it in the clear. That is exactly why the secret naming section of the documentation advises avoiding structured data as secret values. We did not try this on GitHub; but the rule rests on the same logic in both environments: masking is text matching. Make every value a separate secret. GitHub also automatically masks some formats that are not defined as secrets (JWTs (JSON Web Tokens), NuGet API (Application Programming Interface) keys, connection strings, GitHub tokens and so on), but the documentation itself says this is not guaranteed.

Secrets and if: Conditions

When you want to say "use the private feed if the secret is defined", the first thing that comes to mind is if: ${{ secrets.NUGET_FEED_TOKEN != '' }}. actionlint catches it:

.github/workflows/bad-perms.yml:13:13: context "secrets" is not allowed here. available contexts are "github", "inputs", "needs", "vars". see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability for more details [expression]

The approach the documentation suggests is to put the secret into a job-level environment variable and read env in the condition. In our workflow NUGET_FEED_TOKEN is defined in the job's env::

      - name: Only when the secret is set
        if: ${{ env.NUGET_FEED_TOKEN != '' }}
        run: echo "private NuGet feed configured"

With the secret present the step ran (private NuGet feed configured); when we ran act without NUGET_FEED_TOKEN, it was skipped:

[secrets-demo/secrets] [DEBUG] Skipping step 'Only when the secret is set' due to '${{ env.NUGET_FEED_TOKEN != '' }}'

This pattern is especially useful for pull requests from forks, because secrets arrive empty there.

Fork Pull Requests, Dependabot and Secrets

The documented rule is clear: no secrets other than GITHUB_TOKEN are sent to a workflow triggered from a fork, and in that case GITHUB_TOKEN is read-only. The reason is simple: whoever opens the fork can also change the workflow file; if secrets were sent, a pull request that writes echo $SECRET | base64 would take them away. Pull requests opened by Dependabot also get a read-only token by default and can only access Dependabot secrets.

The practical consequence: your CI must be able to run without secrets on fork pull requests. Skip the step that needs the private feed with the env condition above, and run integration tests against a service container that needs no secrets. The need for "secrets on forks too" pushes people towards pull_request_target, and as we saw in the second post that door needs care; we will cover the safe patterns in the security post.

GITHUB_TOKEN: What It Is, How Long It Lives, What It Can Do

When each job starts, GitHub produces an installation access token on behalf of the GitHub Actions app installed on the repository and provides it as secrets.GITHUB_TOKEN (or github.token). According to the documentation, the token expires when the job finishes or at the latest when its effective maximum lifetime runs out: at most 6 hours on a GitHub-hosted runner, 24 hours on a self-hosted runner. Its rights are limited to the repository that contains the workflow.

act does not produce a GITHUB_TOKEN; unless you provide one, it is empty:

      - name: GITHUB_TOKEN in act
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: if [ -z "$GH_TOKEN" ]; then echo "GITHUB_TOKEN is empty"; else echo "GITHUB_TOKEN is set"; fi
[secrets-demo/secrets]   | GITHUB_TOKEN is empty

In this series we never gave act a real token. Steps that need a token (creating a release, pushing an image to GHCR (GitHub Container Registry)) we do not run in act; we validate them with actionlint.

An important detail: an action can reach the token through the github.token context even if you do not pass it explicitly. So "I didn't give this action a token" does not mean the action cannot reach the token. The only way to limit what the token can do is permissions:.

Least Privilege with permissions

All of Bookstore's workflows start with this line at the top, and jobs that need write access open only the scope they need at their own level:

permissions:
  contents: read

jobs:
  github-release:
    permissions:
      contents: write

The rule: if you write even one scope under permissions, every scope you did not write becomes none. Job-level permissions completely replaces the workflow-level setting for that job; it does not add to it. The default permissions come from the repository, organization or enterprise setting. With the February 2, 2023 changelog GitHub switched the default to read-only for newly created personal repositories and new enterprises, while repositories owned by an organization follow the organization's setting; but the change did not touch existing repositories and organizations. So in an old organization the "Read and write permissions" default may still be on; writing an explicit permissions: at the top of the workflow removes that uncertainty.

The scope list in the documentation grows over time. The Actions changelog from the first week of September 2026 added a new scope: vulnerability-alerts. Reading Dependabot alerts used to require a PAT (Personal Access Token) or a GitHub App; now vulnerability-alerts: read is enough. Only read and none are accepted. Here is another place where tools lag behind new features: actionlint 1.7.12 does not know this scope and raises a false alarm. In a file we broke on purpose:

permissions:
  contents: admin
  vulnerability-alerts: read
  pull-request: write
$ actionlint .github/workflows/bad-perms.yml
.github/workflows/bad-perms.yml:6:13: "admin" is invalid as permission of scope "contents". available values are "read", "write", "none" [permissions]
.github/workflows/bad-perms.yml:7:3: unknown permission scope "vulnerability-alerts". all available permission scopes are "actions", "artifact-metadata", "attestations", "checks", "contents", "deployments", "discussions", "id-token", "issues", "models", "packages", "pages", "pull-requests", "repository-projects", "security-events", "statuses" [permissions]
.github/workflows/bad-perms.yml:8:3: unknown permission scope "pull-request". all available permission scopes are "actions", "artifact-metadata", "attestations", "checks", "contents", "deployments", "discussions", "id-token", "issues", "models", "packages", "pages", "pull-requests", "repository-projects", "security-events", "statuses" [permissions]

The first and third errors are real: admin is not a valid level, and pull-request is a typo of pull-requests. The second is a false alarm; GitHub's current documentation lists vulnerability-alerts. actionlint's list also has models and repository-projects, which are not in the documentation's current list. Trust the tool, but know its dates: if a warning concerns a new GitHub feature, check the documentation first.

PAT or GitHub App Token?

GITHUB_TOKEN cannot do some things: write to another repository, make a push that triggers new workflow runs (we saw this in the second post), act at the organization level. For those you use either a PAT or a GitHub App. We discussed PATs in the Git & GitHub series' 13th post, about connecting to GitHub and Personal Access Tokens; in CI a PAT has two problems: it is tied to a person (when that person leaves the company, the workflow breaks) and it is usually long-lived. The approach the documentation also recommends is a GitHub App: fine-grained permissions, a short-lived token, and not tied to a person. actions/create-github-app-token (v3.3.0 as of November 2026) produces a new installation token in every job from the App's client ID and private key:

      - id: app-token
        uses: actions/create-github-app-token@v3
        with:
          client-id: ${{ vars.BOOKSTORE_APP_CLIENT_ID }}
          private-key: ${{ secrets.BOOKSTORE_APP_PRIVATE_KEY }}
          permission-contents: write

According to the README the app-id input is now deprecated in favor of client-id, and the token produced is valid for one hour. The client ID is not sensitive, so it is a variable; the private key is a secret. We did not run this step because it needs a real GitHub App; we verified the input names against the action's action.yml.

Production Notes

Manage secrets like an inventory: which secret is used in which workflow and job, and when was it last rotated? Delete unused secrets; when you change a secret's value, revoke the old one in the source system. If you suspect a secret leaked into a log, revoke the secret at the source first, then delete the log; deleting the log alone is not enough, because someone may already have downloaded or seen it.

Write permissions at the top of every workflow, and make an increase in a job's permissions something that needs special attention in pull request review. Lines like contents: write, packages: write and id-token: write deserve a "why?" in review. Tools like OpenSSF (Open Source Security Foundation) Scorecard and zizmor automate this check; we will use them in the security post.

Common Pitfalls

Printing a derived value to the log. base64, URL-encoding, a field from inside JSON, part of a connection string: none of them is masked automatically. Use ::add-mask:: or don't print it at all.

Storing a secret as JSON. The whole JSON is masked, the fields inside it are not. Every value is a separate secret.

Trying to use secrets in if:. The secrets context is not valid there; put the secret into env and check env.

Old repositories with a write-all default. A workflow without permissions: can run with write access on every scope in a pre-2023 organization. Put contents: read at the top of every workflow.

Putting a secret in the workflow-level env:. Every job and every action sees it. Put it at the narrowest level, in the step that needs it.

Putting a sensitive value in a variable. Variables show up in the clear in the log.

A Small Experiment

★☆☆ Give act a fake NUGET_FEED_TOKEN with --secret-file and print the value reversed (rev). Check whether it is masked, then fix it with ::add-mask::.

★★☆ For Bookstore's three workflows (api-ci.yml, web-ci.yml, release.yml), work out which job really needs which scope and write the narrowest permissions blocks. Validate with actionlint.

★★★ Design a small script that builds a secret and variable inventory for an organization: walk the repositories with the GitHub CLI (Command Line Interface), collect the secrets.X and vars.Y names used in each workflow file, and list the ones that are defined but unused. Run the script only in your own test organization.

In this post we thought about secrets at the repository level. A production deploy needs different rules: only from certain branches, with the approval of certain people, and with production-only secrets. In the next post we look at GitHub Actions environments: protection rules, required reviewers, wait timers, deployment branch policies and deployment history.