Skip to main content
DevOps from zero · Lesson 24 of 25 · 14 min

GitHub Actions Secrets Tutorial: Variables, Scopes and Forks

GitHub Actions secrets tutorial: secrets vs variables, repository and environment scope, log masking and its limits, fork pull requests and GITHUB_TOKEN.

By Matthews WongReviewed on

Key takeaway

GitHub Actions secrets are encrypted values, such as API keys and SSH keys, that a job reads through the secrets context; the runner replaces them with *** in the log. Configuration variables hold non-sensitive settings, read through vars, and print as they are. Both live at organization, repository or environment level, the lowest level wins, and a pull request from a fork gets no secrets.

You will be able to

  • Decide whether a value belongs in a secret or a variable, and at which level, and predict which one a job receives when a name exists twice.
  • Read secrets and variables in a job safely: through with or env, never directly in if:, and never on a command line or in a log.
  • Explain what masking, fork pull requests and GITHUB_TOKEN permissions protect, and where each one stops.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

Where a secret goes, and where it does not

A secret is encrypted on your machine, stored in the repository's settings, handed only to a job that names it, masked in the run log, written to the server as an owner-only file, and withheld from pull requests from forks.

Step 1 of 6. You store values in the repository's settings. deploy.yml names what each job needs. The job runs on a GitHub runner, writes a run log, and reaches the server over SSH.

Keys: ← → step · Home / End · K play or pause

  1. You store values in the repository's settings. deploy.yml names what each job needs. The job runs on a GitHub runner, writes a run log, and reaches the server over SSH.

  2. gh secret set encrypts the value on your machine before sending it. GitHub keeps it under Settings, Secrets and variables, Actions, and its API never returns the value.

    gh secret set VPS_SSH_KEY
  3. The deploy job names secrets.VPS_SSH_KEY. GitHub evaluates that expression and substitutes the value before the step runs. A secret no job names never reaches the runner.

    key: secrets.VPS_SSH_KEYPredict the next step A step echoes a line that contains the key. What does the run log show? (The key, in plain text · *** where the key was · Nothing, the step is blocked)
  4. The runner replaces every secret it was given with *** before the line reaches the log. Variables print as they are. A transformed copy can slip through: redaction is not guaranteed.

    Secret: ***
  5. Over SSH, the deploy script writes the rendered values into .env, then chmod 600 lets only the file's owner read or write it. Masking protects logs; on the server, permissions do.

    chmod 600 .envPredict the next step Say this workflow also ran on pull_request, and a fork opened one. What would secrets.VPS_SSH_KEY hold in that run? (The same key, masked · A read-only copy · An empty value)
  6. On pull_request from a fork, GitHub passes no secrets except GITHUB_TOKEN, and that token is read-only. The expression comes back empty, so unreviewed code never holds the key.

    on: pull_request

What are GitHub Actions secrets and variables?

A secret is a value you store for your workflows that must stay private: an API key, an SSH key, a token. GitHub encrypts secrets with libsodium sealed boxes before they reach GitHub, decrypts them to inject them into a workflow run, and a workflow can read a secret only if it explicitly names it. The REST API that lists a repository's secrets returns names and timestamps, never the values.

Read more (1)

A configuration variable is for the rest: GitHub describes variables as a way to store and reuse non-sensitive configuration, and warns that they render unmasked in build output. A port, a deploy path or a feature switch is a variable. Both kinds are read through a context: secrets.NAME for secrets and vars.NAME for variables, written inside an expression as ${{ secrets.NAME }} or ${{ vars.NAME }}. A name that was never set reads as an empty string, for either kind.

How do you create a secret or a variable?

In the browser: open the repository's Settings, then Secrets and variables, then Actions. The Secrets tab has New repository secret and the Variables tab has New repository variable. Environment secrets and variables live under Settings, Environments, inside each environment. You need write access to an organization's repository, or to be a collaborator on a personal one.

Read more (2)

From a terminal, the GitHub CLI does the same. gh secret set NAME prompts for the value, --body takes it from an argument, and a redirect such as gh secret set NAME < file reads it from standard input. The CLI encrypts the value locally before sending it. Add --env NAME for an environment or --org NAME for an organization, and use gh variable set with the same flags for variables. gh secret list shows names only; gh variable list shows values too.

Names follow the same rules for both: letters, digits and underscores only, no spaces, no GITHUB_ prefix, and no digit at the start. Names are case-insensitive when referenced and stored in uppercase, so secrets.vps_ssh_key and secrets.VPS_SSH_KEY are the same secret.

How should a job read a secret?

Hand it to the one step that needs it, as an action input under with: or as an environment variable under env:. GitHub evaluates every expression and substitutes the result before the step runs, so a secret written straight into a run: script becomes part of the script's text, and GitHub warns that secrets often contain special characters that can affect your shell. Put it in env: and quote the shell variable instead.

Read more (2)

Keep secrets off the command line. GitHub's advice is to pass them between processes through environment variables, standard input or another mechanism the program supports, because command lines may be visible to other users or captured by audit events. That is why docker login here receives its token with --password-stdin rather than as an argument.

Two rules surprise people. The secrets context is not available in if: conditions, so copy the secret into a job-level env: and test env.NAME in a step's if: instead. And secrets do not flow into a reusable workflow on their own: the caller passes them by name, or with secrets: inherit inside the same organization or enterprise.

Repository, environment or organization: which value wins?

Secrets and variables both exist at three levels. Organization ones are shared across repositories, with a policy that limits which repositories may use each. Repository ones serve every workflow in the repository. Environment ones belong to a named environment such as production, and only jobs that reference that environment with environment: production can read them. When one name exists at several levels, the lowest level wins: environment over repository, repository over organization.

Read more (2)

Environments are where secrets get a gate. A job that references an environment must pass its protection rules before it runs or reads the environment's secrets. Required reviewers lists up to six people or teams, and one approval lets the job continue. A wait timer delays the job, and deployment branches and tags limit which refs may deploy. Referencing an environment that does not exist yet creates it, without any rules.

In private repositories, some environment features depend on your GitHub plan, so check the environments page before you design around them. And remember who can reach a repository secret anyway: GitHub's hardening guide states that any user with write access to a repository has read access to all the secrets configured in it.

What does log masking hide, and what does it miss?

The runner redacts the value of every secret it was given in the current job, so a line that would print one shows *** instead. GitHub is explicit that this is not guaranteed: a secret can be transformed in many ways, and the runner can only redact secrets used within the current job. Variables render unmasked.

Read more (1)

Three habits keep redaction working. Store one value per secret, never a blob of JSON, XML or YAML, because structured data can make redaction fail. When a step derives a new sensitive value, such as a short-lived token fetched with a secret, register it with the workflow command ::add-mask:: before anything prints it. And if a value does reach a log unredacted, delete that log and rotate the secret.

What do pull requests from forks and GITHUB_TOKEN get?

When a pull request from a fork triggers a workflow, GitHub passes no secrets to the runner except GITHUB_TOKEN, and that token is read-only. Workflows triggered by Dependabot get no secrets either. In a public repository, runs from first-time contributors wait for a maintainer's approval by default. So code you have not reviewed can run your tests, but it never holds your keys.

Read more (2)

pull_request_target is the exception, and the trap. It runs the workflow from the base repository's default branch with the base repository's token and secrets, which is safe only while it runs that trusted code. Check out the pull request's head and run its build there, and the fork's code runs with your secrets; GitHub calls this a pwn request. Use it only when you must, and never to build fork code.

GITHUB_TOKEN is the one secret you never create. GitHub makes a fresh one at the start of every job, scoped to the repository, and it expires when the job finishes, or after at most six hours on a GitHub-hosted runner. A permissions: block, at the top of the workflow or per job, sets what it may do, and every permission you do not list is set to none. New repositories in a personal account default to read access for contents and packages.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

.github/workflows/deploy.yml:4-6
# All app configuration is read from GitHub at run time:
#   Secrets  → GROQ_API_KEY, RESEND_API_KEY, VPS_SSH_HOST, VPS_SSH_USER, VPS_SSH_KEY
#   Variables→ CONTACT_FROM_EMAIL, CONTACT_TO_EMAIL, APP_PORT, DEPLOY_PATH, VPS_SSH_PORT

Ten names, no values: the values live only in GitHub and on the server. Read the split as one rule: anything that grants access, or that should stay out of the run log, is a secret. The server's address and user are not passwords, but as secrets they show as *** in a log instead of being printed. The repository belongs to a personal account and no job names an environment, so all ten are repository-level.

.github/workflows/deploy.yml:203-211
      - name: Copy compose file to VPS
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.VPS_SSH_HOST }}
          username: ${{ secrets.VPS_SSH_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ vars.VPS_SSH_PORT || 22 }}
          source: docker-compose.yml
          target: ${{ vars.DEPLOY_PATH }}

Secrets travel as action inputs under with:, one of the two ways GitHub's docs show, so they never sit in a shell command. The port and the target path are variables. An unset variable reads as an empty string, and vars.VPS_SSH_PORT || 22 is written to fall back to port 22 in that case.

.github/workflows/deploy.yml:226-234
            cat > .env <<'ENVEOF'
            GROQ_API_KEY=${{ secrets.GROQ_API_KEY }}
            RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
            CONTACT_FROM_EMAIL=${{ vars.CONTACT_FROM_EMAIL }}
            CONTACT_TO_EMAIL=${{ vars.CONTACT_TO_EMAIL }}
            IMAGE=${{ needs.build-and-push.outputs.image }}
            APP_PORT=${{ vars.APP_PORT || 3002 }}
            ENVEOF
            chmod 600 .env

GitHub renders every expression before this script leaves the runner, so the server receives real values. Masking only rewrites the GitHub log; it does nothing to a file on another machine. That is why the last line matters: chmod 600 leaves .env readable and writable by its owner only. Lesson 10 covers how Compose reads this file.

.github/workflows/deploy.yml:236
            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

The deploy job's own GITHUB_TOKEN, which its permissions: block limits to contents: read and packages: read, enough to pull the image and nothing more. It goes to docker login through standard input, as GitHub advises, not as a command-line argument. The token expires when the job ends, and the script runs docker logout once the containers are up.

What breaks

Mistakes that cost real time

  • The secret is empty inside the job

    An expression for a secret the run does not have returns an empty string, so the step carries on with nothing. Check, in order: the job runs from a fork's pull request or from Dependabot, which get no secrets; the secret is an environment secret but the job has no environment: key; a reusable workflow was called without passing it; or the name is spelt differently.

  • A condition that reads a secret

    if: secrets.DEPLOY_KEY != '' looks reasonable, but the secrets context is not available in if: keys. Set the secret as a job-level env: entry and write the step's condition as if: env.DEPLOY_KEY != '' instead, which is the pattern GitHub documents.

  • A secret shows up in a log anyway

    Redaction matches what the runner knows, so a transformed copy, a value inside a JSON blob, or a token derived from a secret can print in the clear. Delete the log and rotate the secret first. Then store one value per secret, register derived values with ::add-mask::, and stop printing the value at all.

  • pull_request_target builds a fork's code with your secrets

    A workflow switched to pull_request_target so that fork pull requests can reach a secret, then checks out the pull request head and runs npm install. The fork's code now runs with your token and secrets. Keep builds of fork code on pull_request, and use pull_request_target only for trusted work such as labelling.

Secrets and configuration variables side by side, with their scopes, as GitHub documents them on 2026-10-18.
Meant forAPI keys, SSH keys, tokens: anything you would not post publiclyNon-sensitive configuration: ports, paths, switches
Read withsecrets.NAME, in with: or env:, not in if:vars.NAME, also in if: and environment:
In the run logShown as ***, not guaranteed once transformedPrinted as it is
Never setEmpty stringEmpty string
LevelsOrganization, repository, environmentOrganization, repository, environment
Same name on two levelsLowest level wins: environment, then repository, then organizationSame rule
Environment levelOnly jobs that name the environment, after its protection rules passOnly jobs that name the environment
Limits100 per repository, 100 per environment, 48 KB each500 per repository, 100 per environment, 48 KB each
Try it yourself

Do it on your own machine

Use a new public test repository of your own and throwaway values, never a real key. You need the GitHub CLI, logged in with gh auth login. A second GitHub account, or a friend's, is needed only for the fork step.

  1. Create the test repository with a README, so it has a main branch, and clone it.

    gh repo create secrets-lab --public --clone --add-readme
    cd secrets-lab
  2. Set one secret and one variable. gh secret list shows the name and when it changed, never the value; gh variable list shows the value as well.

    gh secret set DEMO_SECRET --body "practice-only"
    gh variable set DEMO_GREETING --body "hello from a variable"
    gh secret list
    gh variable list
  3. Add a workflow that reads both through a job-level env:, runs one step only when the secret arrived, and masks a value it makes itself. Commit and push.

    mkdir -p .github/workflows
    cat > .github/workflows/lab.yml <<'EOF'
    name: Secrets lab
    on: [push, pull_request]
    permissions:
      contents: read
    jobs:
      show:
        runs-on: ubuntu-latest
        env:
          DEMO_SECRET: ${{ secrets.DEMO_SECRET }}
          DEMO_GREETING: ${{ vars.DEMO_GREETING }}
        steps:
          - name: Print a variable and a secret
            run: |
              echo "Variable: $DEMO_GREETING"
              echo "Secret: $DEMO_SECRET"
          - name: Run only when the secret arrived
            if: env.DEMO_SECRET != ''
            run: echo "The secret reached this run"
          - name: Mask a value you made yourself
            run: |
              derived="lab-$RANDOM"
              echo "::add-mask::$derived"
              echo "Derived: $derived"
    EOF
    git add .github
    git commit -m "Add a secrets lab workflow"
    git push
  4. Give the run a few seconds to start, then watch it and read its log. The variable prints as typed, while the secret and the derived value show ***.

    run_id=$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')
    gh run watch "$run_id"
    gh run view "$run_id" --log | grep -E "Variable:|Secret:|Derived:|reached"
  5. In the browser, open Settings, Environments, New environment. Name it lab, tick Required reviewers, add yourself and save. Give lab its own DEMO_SECRET with a longer value, and add a job that names the environment. The run waits for your approval; approve it on the run page, and the log prints Length 20, the environment's value, not 13.

    gh secret set DEMO_SECRET --env lab --body "lab-environment-only"
    cat >> .github/workflows/lab.yml <<'EOF'
      gated:
        if: github.event_name == 'push'
        runs-on: ubuntu-latest
        environment: lab
        steps:
          - name: Which DEMO_SECRET won?
            env:
              DEMO_SECRET: ${{ secrets.DEMO_SECRET }}
            run: echo "Length ${#DEMO_SECRET}"
    EOF
    git commit -am "Add a gated job"
    git push
  6. From a second account, fork secrets-lab, edit README.md in the fork and open a pull request. If GitHub asks, approve the run: first-time contributors need approval by default. In that run, Secret: prints nothing after the colon, and the step that needs the secret is skipped.

  7. Delete the test repository. Deleting needs the delete_repo scope, and --yes skips the prompt only when you name the repository as owner/name.

    cd ..
    gh auth refresh -s delete_repo
    gh repo delete YOUR-USER/secrets-lab --yes
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is graded, so guess freely; a miss is kept in this browser so the course can ask it again later.

  1. 1.DEPLOY_KEY exists as a repository secret and as a secret in the production environment. A job with environment: production reads secrets.DEPLOY_KEY. Which value does it get?
  2. 2.Your public repository runs its tests on pull_request. Someone opens a pull request from their fork. What does that run get?
  3. 3.A step uses a secret to fetch a short-lived token, and later steps print commands that contain that token. How do you keep it out of the log?
From earlier lessons

Two questions from earlier lessons

Recalling an older lesson after a gap is what makes it stick. Answer from memory before you look anything up.

  1. 1.An Alpine-based image checks wget --spider http://localhost:3000/, and the app listens on 0.0.0.0. The health log says Connection refused, yet the app answers. What is wrong?
  2. 2.This site's compose file has - "127.0.0.1:${APP_PORT:-3002}:3000". What turns ${APP_PORT:-3002} into 3002?

From: 23. Docker Healthcheck Tutorial: Health States and Restart Policies · 21. YAML Tutorial for Beginners: Maps, Lists, Quotes and Traps

Think it through

Answer in your head, then check

  1. 1.This site's VPS_SSH_HOST is not a password. Why is it a secret rather than a variable?Show the answer

    Answer:Because variables print unmasked and secrets are redacted. As a secret, the server's address shows as *** in every run log instead of being written out in full.

  2. 2.How do you run a step only when a secret is set?Show the answer

    Answer:The secrets context is not available in if:. Copy the secret into a job-level env: entry and test it in the step's condition: if: env.NAME != ''.

  3. 3.The deploy job lists contents: read and packages: read. What may its GITHUB_TOKEN do with issues?Show the answer

    Answer:Nothing. Once a permissions: block lists any permission, every permission it does not list is set to none, so the token can read the repository and pull packages and nothing else.

Go deeper

Related deep dives on this site