Variables and secrets let you configure your workflows without hardcoding values. Variables carry non-sensitive configuration; secrets carry confidential data: tokens, passwords, API keys.
What you will learn
- Tell variables and secrets apart and know which to use
- Define variables at four levels: workflow, job, step, configuration
- Consume a secret without ever exposing it in the logs
- Understand the
GITHUB_TOKENand environment secrets - Pass values between steps and between jobs through outputs
- Avoid the traps: injection through
GITHUB_ENV, empty secrets on forks
This guide assumes you already know how to write a workflow.
Variables versus secrets
Variables and secrets are declared in the same place and used in similar ways, but their treatment differs radically: a variable shows in clear text, a secret is masked by GitHub everywhere it would appear. Choosing between them therefore depends on the sensitivity of the data.
| Aspect | Variables | Secrets |
|---|---|---|
| Visibility | Clear text in the logs | Masked (***) |
| Editing | UI, API, CLI | UI, API, CLI |
| Access | ${{ vars.NAME }} | ${{ secrets.NAME }} |
| Usage | Config, feature flags | Tokens, passwords, keys |
Environment variables
An environment variable makes a value available to the shell commands of a step. GitHub Actions lets you declare them at several scope levels, and also offers persistent configuration variables.
The levels of definition
Variables can be defined at four levels, from broadest to most precise. The most precise level overrides the ones above.
# 1. Workflow levelenv: GLOBAL_VAR: 'workflow-level'
jobs: build: # 2. Job level env: JOB_VAR: 'job-level' runs-on: ubuntu-24.04 steps: # 3. Step level - name: A step with variables env: STEP_VAR: 'step-level' run: | echo "Global: $GLOBAL_VAR" echo "Job: $JOB_VAR" echo "Step: $STEP_VAR"Configuration variables (vars)
Configuration variables (vars) are defined in the settings of the
repository, the environment or the organisation. They persist from
one run to the next, which makes them ideal for an API URL or a feature
flag.
steps: - name: Read a configuration variable run: | echo "Environment: ${{ vars.ENVIRONMENT }}" echo "API URL: ${{ vars.API_URL }}" echo "Feature flag: ${{ vars.ENABLE_NEW_FEATURE }}"Creating a variable:
- Repository, then Settings, then Secrets and variables, then Actions
- The "Variables" tab
- "New repository variable"
Dynamic variables (outputs)
When a value is only known at run time (a version, a commit hash, a timestamp),
a step computes it and writes it to $GITHUB_OUTPUT. Later steps read it
through steps.<id>.outputs.<name>.
steps: - name: Generate the variables id: vars run: | echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" echo "timestamp=$(date +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT"
- name: Reuse the variables run: | echo "Version: ${{ steps.vars.outputs.version }}" echo "SHA: ${{ steps.vars.outputs.sha_short }}" echo "Timestamp: ${{ steps.vars.outputs.timestamp }}"Variables between jobs
For a job to pass a value to another, it exposes it in its outputs: block. The
receiving job declares it in needs: and reads the value through
needs.<job>.outputs.<name>.
jobs: setup: runs-on: ubuntu-24.04 outputs: version: ${{ steps.version.outputs.value }} steps: - name: Determine the version id: version run: echo "value=1.2.3" >> "$GITHUB_OUTPUT"
build: needs: setup runs-on: ubuntu-24.04 env: VERSION: ${{ needs.setup.outputs.version }} steps: - run: echo "Building version $VERSION"Secrets
A secret is a confidential value GitHub encrypts at rest and masks in
the logs. This section covers their scope levels, how to consume them, and
the special case of the GITHUB_TOKEN.
Kinds of secrets
Like variables, secrets are declared at three scopes. The broader the scope, the larger the exposure surface, so pick the narrowest one that answers the need.
| Level | Access | Configuration |
|---|---|---|
| Repository | This repository only | Settings, then Secrets |
| Environment | Jobs carrying that environment | Settings, then Environments |
| Organisation | Every repository of the organisation | Org Settings, then Secrets |
Using a secret
A secret is always consumed through an env: block: the value becomes an
environment variable the script reads without ever appearing in the workflow
definition. Some actions also accept a token as a with: parameter.
steps: # The recommended way: pass the secret through an env: block - name: Deploy the application env: API_KEY: ${{ secrets.API_KEY }} run: ./deploy.sh
# Some actions expect a token as a with: parameter - name: Clone a private repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: my-org/private-repo token: ${{ secrets.REPO_ACCESS_TOKEN }} persist-credentials: falseThe GITHUB_TOKEN secret
The GITHUB_TOKEN is an automatic secret, generated for each workflow run
and revoked at the end. There is nothing to create: it is always available in
secrets.GITHUB_TOKEN.
steps: - name: Check out the code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- name: Create an issue env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh issue create \ --title "Incident detected by the workflow" \ --body "Issue opened automatically by CI."Its permissions depend on the workflow and repository configuration; declare
them explicitly with a permissions: block.
Environment secrets
For deployments, secrets tied to an environment are safer: they are only injected into the jobs attached to that environment, and they can require a manual approval before the run.
jobs: deploy-staging: environment: staging runs-on: ubuntu-24.04 steps: - name: Deploy to staging env: # A secret specific to the "staging" environment DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: ./deploy.sh
deploy-production: environment: production runs-on: ubuntu-24.04 steps: - name: Deploy to production env: # A different secret, attached to the "production" environment DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: ./deploy.shSecurity good practices
A few reflexes avoid the most common leaks. The underlying principle, never
interpolate a secret inside a run:, is introduced in
Managing secrets; the
practices below apply it day to day.
Never expose a secret
A secret interpolated directly into a command appears in the logs; even
masked, one transformation is enough to reveal it. Pass it through env: and
let the script read it.
# ❌ DANGEROUS: the secret is visible in the logs- run: echo ${{ secrets.API_KEY }}- run: curl -H "Authorization: Bearer ${{ secrets.NPM_TOKEN }}" https://registry.npmjs.org/-/whoami
# ❌ DANGEROUS: the secret is copied into a shell variable that is printed- run: | KEY=${{ secrets.API_KEY }} echo "Key used: $KEY"
# ✅ SECURE: pass the secret through env, the script reads it- name: Call the API env: API_KEY: ${{ secrets.API_KEY }} run: ./script.shMask dynamic values
A sensitive value generated at run time is unknown to GitHub, so it is not
masked automatically. The ::add-mask:: command adds it to the list of values
censored in the logs.
- name: Generate the token id: token run: | TOKEN=$(./generate-token.sh) echo "::add-mask::$TOKEN" echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
- name: Use the token env: TOKEN: ${{ steps.token.outputs.token }} run: ./use-token.shRestrict access to secrets
Secrets are not exposed to pull requests from forks, so an outside contributor cannot exfiltrate them. For sensitive jobs, check explicitly that the run does not come from a fork.
jobs: build: # This job runs on the main repository: secrets are available runs-on: ubuntu-24.04 steps: - name: A step using a secret env: SECRET: ${{ secrets.MY_SECRET }} run: echo "Secret available"
build-fork: # We check that the PR does not come from a fork if: github.event.pull_request.head.repo.fork == false runs-on: ubuntu-24.04 steps: - name: A conditional step env: SECRET: ${{ secrets.MY_SECRET }} run: echo "Trusted origin, secret available"Plan secret rotation
A secret that never rotates eventually leaks. A scheduled workflow can remind
you of the deadline by opening an issue. Since it writes to the repository, it
declares the minimal issues: write permission and nothing more.
name: Secret rotation reminder
on: schedule: - cron: '0 9 1 */3 *' # The 1st of each quarter, at 09:00
permissions: {}
jobs: remind: runs-on: ubuntu-24.04 permissions: issues: write steps: - name: Create the reminder issue env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh issue create \ --title "Secret rotation required" \ --body "It is time to rotate the repository secrets."Common patterns
Variables and secrets combine into a few recurring shapes: per-environment configuration, feature flags, multi-line secrets, and loading from a file.
Per-environment configuration
A matrix over the environments lets you deploy staging and production with
the same job, each receiving its own vars and secrets.
env: APP_NAME: my-app
jobs: deploy: runs-on: ubuntu-24.04 strategy: matrix: environment: [staging, production] environment: ${{ matrix.environment }} steps: - name: Deploy to the target env: TARGET_ENV: ${{ matrix.environment }} # Environment-specific variables (defined in vars) API_URL: ${{ vars.API_URL }} REPLICAS: ${{ vars.REPLICAS }} # An environment-specific secret DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: | echo "Deploying $APP_NAME to $TARGET_ENV" echo "API URL: $API_URL" echo "Replicas: $REPLICAS"Feature flags
Configuration variables act as build switches: you read them through env:,
then the script composes the options accordingly.
jobs: build: runs-on: ubuntu-24.04 steps: - name: Check out the code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- name: Build with the active options env: ENABLE_FEATURE_X: ${{ vars.ENABLE_FEATURE_X }} ENABLE_FEATURE_Y: ${{ vars.ENABLE_FEATURE_Y }} run: | BUILD_FLAGS="" if [ "$ENABLE_FEATURE_X" = "true" ]; then BUILD_FLAGS="$BUILD_FLAGS --feature-x" fi if [ "$ENABLE_FEATURE_Y" = "true" ]; then BUILD_FLAGS="$BUILD_FLAGS --feature-y" fi npm run build -- $BUILD_FLAGSMulti-line secrets
An SSH key, a certificate or a JSON blob is pasted as is into the
GitHub interface. In the workflow, you write it to a file through env:, with
strict permissions.
- name: Prepare the SSH key env: SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }} run: | mkdir -p ~/.ssh echo "$SSH_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
- name: Prepare the GCP credentials env: GCP_CREDENTIALS: ${{ secrets.GCP_CREDENTIALS }} run: | echo "$GCP_CREDENTIALS" > /tmp/gcp-key.json chmod 600 /tmp/gcp-key.json export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp-key.jsonLoading variables from a file
A versioned .env file can feed $GITHUB_ENV. But never copy it blindly:
uncontrolled content can slip in sensitive variables (PATH, NODE_OPTIONS)
and make the runner execute code.
# ❌ Dangerous: the whole file is injected without any check- run: cat .env >> "$GITHUB_ENV"
# ✅ Safe: read the expected keys explicitly- name: Load the version from the file run: | VERSION=$(grep '^VERSION=' .env | cut -d= -f2) echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"Feeding GITHUB_ENV with untrusted data is an environment injection; the
mechanism and its countermeasures are covered in
Contexts and expressions.
Debugging
When a variable arrives empty or unexpected, two reflexes help: inspect the runner environment and turn on verbose logs.
Inspect the available variables
A diagnostic step prints the GitHub context and the environment variables.
Context values travel through env:, which is also the rule that keeps the
runner safe from injection.
- name: Inspect the environment env: GH_REPOSITORY: ${{ github.repository }} GH_REF: ${{ github.ref }} GH_EVENT: ${{ github.event_name }} ALL_VARS: ${{ toJSON(vars) }} run: | echo "=== GitHub context ===" echo "Repository: $GH_REPOSITORY" echo "Ref: $GH_REF" echo "Event: $GH_EVENT" echo "=== Configuration variables ===" echo "$ALL_VARS" echo "=== Environment variables ===" env | sortTurn on verbose logs
Two variables switch GitHub Actions into verbose mode: one for the runner, one for the steps. Keep them for diagnosis, they make the logs considerably heavier.
env: ACTIONS_RUNNER_DEBUG: true # Verbose runner logs ACTIONS_STEP_DEBUG: true # Verbose step logsCommon mistakes
Three confusions come up constantly around variables and secrets. Recognising them saves hours of debugging.
The secret is not available
On a fork PR, secrets are deliberately empty. A step that depends on one should check rather than fail silently.
# ❌ The secret is empty on fork PRs- run: echo "Secret: ${{ secrets.MY_SECRET }}" # Result: "Secret: " (empty)
# ✅ Check before use- name: Check that the secret is present if: secrets.MY_SECRET != '' run: echo "Secret available"The variable is not defined
A missing configuration variable returns an empty string, not an error. Plan
a default value with the || operator.
# ❌ An empty string when vars.OPTIONAL does not exist- run: echo "${{ vars.OPTIONAL }}"
# ✅ An explicit default value- run: echo "${{ vars.OPTIONAL || 'default-value' }}"Confusing env and vars
vars refers to the repository configuration (the Settings tab); env
refers to an environment variable declared in the workflow. The two
notations look alike but do not point at the same thing.
# vars = repository configuration (defined in Settings)- run: echo "${{ vars.API_URL }}"
# env = a workflow environment variableenv: MY_VAR: valuesteps: - run: echo "${{ env.MY_VAR }}" - run: echo "$MY_VAR" # Direct shell accessKey points
- Variables are non-sensitive configuration, visible in the logs; secrets are confidential data, masked automatically by GitHub.
- Variables are defined at four levels: workflow, job, step, configuration (
vars); the most precise wins. - A secret is always consumed through an
env:block, never interpolated in clear text intorun:nor passed as a visible argument. - The
GITHUB_TOKENis generated automatically for every run; declare its permissions explicitly. - Never run
cat file >> $GITHUB_ENVblindly: uncontrolled content injects variables and can execute code. - On fork PRs secrets are empty; prefer environment secrets and gate the sensitive jobs.
Next steps
- Reusable workflows: passing secrets and inputs by name to a called workflow, without
secrets: inherit. - Securing GitHub Actions: the full hardening path, from token permissions to action pinning.
- OIDC: deploying without secrets: removing long-lived cloud credentials rather than trying to protect them.