# 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.

URL: https://www.matthewswong.com/en/learn/devops-from-zero/github-actions-secrets-and-variables/

DevOps from zero · Lesson 24 of 25 · Reviewed on 18 October 2026 · By Matthews Wong (https://www.matthewswong.com/en/about/)

> 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.

## 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.

1. **Meet the parts.** 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. **Encrypt, then store.** 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. **Only what the job names.** 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_KEY
   ```

   *Predict:* 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. **Masked in the log.** 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. **Onto the server, owner-only.** 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 .env
   ```

   *Predict:* 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. **A fork gets no secrets.** 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.

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.

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.

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.

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.

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.

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.

## The real config behind this lesson

`.github/workflows/deploy.yml:4-6`

```yaml
# 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`

```yaml
      - 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`

```yaml
            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`

```bash
            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

- **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.

| Aspect | Secret | Configuration variable |
| --- | --- | --- |
| Meant for | API keys, SSH keys, tokens: anything you would not post publicly | Non-sensitive configuration: ports, paths, switches |
| Read with | secrets.NAME, in with: or env:, not in if: | vars.NAME, also in if: and environment: |
| In the run log | Shown as ***, not guaranteed once transformed | Printed as it is |
| Never set | Empty string | Empty string |
| Levels | Organization, repository, environment | Organization, repository, environment |
| Same name on two levels | Lowest level wins: environment, then repository, then organization | Same rule |
| Environment level | Only jobs that name the environment, after its protection rules pass | Only jobs that name the environment |
| Limits | 100 per repository, 100 per environment, 48 KB each | 500 per repository, 100 per environment, 48 KB each |

## Try it yourself

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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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 ***.

   ```bash
   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.

   ```bash
   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.

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

## Quick quiz

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?**
   A. The repository's
   B. The environment's
   C. Neither: the run fails on the clash
   Answer: B. When a name exists at several levels, the lowest level wins, and environment is the lowest. A job without environment: production would get the repository's value instead.

2. **Your public repository runs its tests on pull_request. Someone opens a pull request from their fork. What does that run get?**
   A. Every repository secret, masked in the log
   B. The secrets, once a maintainer approves the run
   C. No secrets, only a read-only GITHUB_TOKEN
   Answer: C. Secrets are not passed to the runner for a workflow triggered from a fork, except GITHUB_TOKEN, which is read-only there. Approving a first-time contributor's run lets it start; it does not add secrets.

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?**
   A. Register it with ::add-mask:: before it is printed
   B. Nothing to do: it came from a secret
   C. Store it in a configuration variable
   Answer: A. The runner redacts the secrets it was given, not values derived from them. GitHub's advice is to register a derived value with ::add-mask:: so it is treated as a secret, and variables are never masked.

## Think it through

- **This site's VPS_SSH_HOST is not a password. Why is it a secret rather than a variable?** 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.
- **How do you run a step only when a secret is set?** 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 != ''.
- **The deploy job lists contents: read and packages: read. What may its GITHUB_TOKEN do with issues?** 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

- [Secret Scanning and Preventing Credentials Leaks in GitHub](https://www.matthewswong.com/en/blog/secret-scanning-github-prevention/)
- [GitHub Actions OIDC: Keyless Cloud Auth for CI/CD](https://www.matthewswong.com/en/blog/github-actions-oidc-keyless-cloud-auth/)
- [Automated Secrets Rotation: A Strategy That Survives Audits](https://www.matthewswong.com/en/blog/automated-secrets-rotation-strategy/)
- [Encrypting Secrets in Git with SOPS and age](https://www.matthewswong.com/en/blog/sops-age-encrypted-secrets-git/)

## Sources

- [GitHub Docs: Using secrets in GitHub Actions](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets)
- [GitHub Docs: Secrets (encryption, explicit use, redaction)](https://docs.github.com/en/actions/concepts/security/secrets)
- [GitHub Docs: Secrets reference (naming, precedence, limits)](https://docs.github.com/en/actions/reference/security/secrets)
- [GitHub Docs: Variables (non-sensitive, unmasked)](https://docs.github.com/en/actions/concepts/workflows-and-actions/variables)
- [GitHub Docs: Store information in variables](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-variables)
- [GitHub Docs: Variables reference (naming, precedence, limits)](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)
- [GitHub Docs: Contexts reference (secrets, vars, availability)](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)
- [GitHub Docs: Deployments and environments (protection rules)](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments)
- [GitHub Docs: Managing environments for deployment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments)
- [GitHub Docs: Secure use reference (hardening)](https://docs.github.com/en/actions/reference/security/secure-use)
- [GitHub Docs: Script injections (expression substitution)](https://docs.github.com/en/actions/concepts/security/script-injections)
- [GitHub Docs: Workflow commands (add-mask)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands)
- [GitHub Docs: Events that trigger workflows (forks)](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)
- [GitHub Docs: Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target)
- [GitHub Docs: GITHUB_TOKEN (creation, expiry)](https://docs.github.com/en/actions/concepts/security/github_token)
- [GitHub Docs: Workflow syntax (permissions)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Docs: Actions settings for a repository (token defaults, fork approval)](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository)
- [GitHub Docs: Reuse workflows (passing secrets)](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows)
- [GitHub Docs: REST API endpoints for Actions secrets](https://docs.github.com/en/rest/actions/secrets)
- [GitHub CLI manual: gh secret set](https://cli.github.com/manual/gh_secret_set)
- [GitHub CLI manual: gh variable set](https://cli.github.com/manual/gh_variable_set)

---

- HTML version: https://www.matthewswong.com/en/learn/devops-from-zero/github-actions-secrets-and-variables/
- Bahasa Indonesia: https://www.matthewswong.com/id/learn/devops-from-zero/github-actions-secrets-and-variables/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
