Skip to content
Français
CI/CD & Automatisation medium

Variables and secrets in GitHub Actions

40 min de lecture

Read this page in French

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

AspectVariablesSecrets
VisibilityClear text in the logsMasked (***)
EditingUI, API, CLIUI, API, CLI
Access${{ vars.NAME }}${{ secrets.NAME }}
UsageConfig, feature flagsTokens, 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 level
env:
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:

  1. Repository, then Settings, then Secrets and variables, then Actions
  2. The "Variables" tab
  3. "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.

LevelAccessConfiguration
RepositoryThis repository onlySettings, then Secrets
EnvironmentJobs carrying that environmentSettings, then Environments
OrganisationEvery repository of the organisationOrg 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: false

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

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

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

Restrict 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_FLAGS

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

Loading 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 | sort

Turn 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 logs

Common 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 variable
env:
MY_VAR: value
steps:
- run: echo "${{ env.MY_VAR }}"
- run: echo "$MY_VAR" # Direct shell access

Key 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 into run: nor passed as a visible argument.
  • The GITHUB_TOKEN is generated automatically for every run; declare its permissions explicitly.
  • Never run cat file >> $GITHUB_ENV blindly: uncontrolled content injects variables and can execute code.
  • On fork PRs secrets are empty; prefer environment secrets and gate the sensitive jobs.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn