Skip to main content

Environment Variables, Secrets and OIDC

Keep configuration in GitHub Actions variables and credentials in secrets, see how masking works, and replace long-lived cloud keys with OIDC tokens.

A workflow needs two kinds of settings that do not belong in its YAML: configuration that differs between repositories or environments, such as a site title or an API URL, and credentials, such as an API token. GitHub Actions keeps the first in variables and the second in secrets. For cloud providers there is a better option than storing any credential at all: OIDC lets a job prove who it is and get short-lived cloud credentials for that one job.

In this part you set a variable and a secret with the GitHub CLI, see how GitHub masks secrets in logs, use the variable in the build, and look inside a real OIDC token.

Prerequisites

  • The repository from Part 4.
  • Admin access to it, to change its settings.

Where Settings Live

Kind Where you set it In the workflow Good for
env: in the YAML The workflow file $NAME or ${{ env.NAME }} Values that are the same everywhere
Configuration variables Repository, environment or organization ${{ vars.NAME }} Non-secret settings: titles, URLs, regions
Secrets Repository, environment or organization ${{ secrets.NAME }} Tokens, passwords, keys (up to 48 KB each)
GITHUB_TOKEN Created automatically for every job ${{ secrets.GITHUB_TOKEN }} or ${{ github.token }} Calls to the GitHub API for this repository
OIDC token Requested by the job The cloud provider's login action Short-lived cloud credentials

env: can be set for the whole workflow, a job or a step; the narrowest one wins. A step can also set variables for the steps after it by appending NAME=value lines to the file in $GITHUB_ENV, and add directories to the PATH through $GITHUB_PATH.

Values set at more than one level follow the same idea: an environment's variable or secret overrides the repository's, which overrides the organization's.

Set a Variable and a Secret

The build script reads the page title from SITE_TITLE. Store it as a repository variable, and create a secret for the demonstration. Piping the value in keeps it out of your shell history:

The same settings are in Settings > Secrets and variables > Actions. GitHub encrypts secrets before storing them, and nobody can read a secret back, not even an admin; you can only replace it.

Use Them in a Workflow

This manual workflow uses both, and tries to print the secret:

name: Config and secrets

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  show-config:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    env:
      SITE_TITLE: ${{ vars.SITE_TITLE }}
    steps:
      - name: Use a variable and a secret
        env:
          API_TOKEN: ${{ secrets.DEMO_API_TOKEN }}
        run: |
          if [ -z "$API_TOKEN" ]; then
            echo "::error::The DEMO_API_TOKEN secret is not set"
            exit 1
          fi
          echo "Site title: $SITE_TITLE"
          echo "Token length: ${#API_TOKEN}"
          echo "Printing the token anyway: $API_TOKEN"

      - name: Mask a value you create
        run: |
          session_id="session-$RANDOM$RANDOM"
          echo "::add-mask::$session_id"
          echo "Session: $session_id"

  oidc:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      id-token: write
    steps:
      - name: Request an OIDC token and show its claims
        run: |
          token=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
          echo "::add-mask::$token"
          jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson
            | {iss, aud, sub, repository, ref, event_name, runner_environment}' <<< "$token"

Run it with gh workflow run config.yml. The first job's output:

GitHub replaced the secret with *** everywhere in the log, including the env: listing at the start of the step. The value created in the step was masked too, because the step registered it with ::add-mask:: before printing it. A secret that is missing is an empty string, not an error, which is why the step checks for it first.

Masking has limits. It replaces the exact string, so a value you transform (base64-encode, reverse, cut in half) is printed in clear. GitHub cannot mask what it does not know is secret, such as a token you read from a file, unless you add a mask. And masking only covers logs and job summaries: a secret written to an artifact or a cache is stored as it is.

Now use the variable for real. In the build job of ci.yml, pass it to the build step:

- run: npm run build
  env:
    SITE_TITLE: ${{ vars.SITE_TITLE }}

If the variable does not exist, vars.SITE_TITLE is empty and the build falls back to its default title.

Who Gets the Secrets

  • Pull requests from forks get no secrets (except GITHUB_TOKEN, which is read-only). Otherwise anyone could open a pull request that prints them. Plan for it: steps that need a secret should be skipped or use test values in fork pull requests.
  • Dependabot pull requests get Dependabot secrets, a separate list under the same settings page, not your Actions secrets.
  • Environment secrets are released only to jobs that run in that environment, and only after its protection rules pass, such as an approval. Part 6 uses this for deployments.
  • Organization secrets can be limited to selected repositories.

Prefer the automatic GITHUB_TOKEN over a personal access token. It expires when the job ends, it can only access this repository, and the permissions: block limits it further. Part 9 covers it.

OIDC Instead of Cloud Keys

The classic way to deploy to a cloud is to create an access key, store it as a secret and hope it never leaks. It lives until someone rotates it, and anyone who gets it can use it from anywhere.

With OpenID Connect (OIDC), the job asks GitHub for a signed token that says which repository, branch, workflow and environment it is running for. The cloud provider checks the signature and the claims against a trust policy you created once, and returns credentials that expire after about an hour. No long-lived key exists.

The oidc job above requests a token with permissions: id-token: write and prints a few of its claims. It masks the token itself and never prints it:

The sub (subject) claim is what trust policies match on. Notice its format: repo:The-DevOps-Daily@212527617/guide-cicd-demo-2026-10-24@1431822257:ref:refs/heads/main. Since 15 July 2026, repositories created or renamed after that date get the owner and repository IDs after an @. The IDs never change, so if someone later creates an organization or repository with the same name, their tokens do not match your trust policy. Older repositories keep repo:owner/name:... unless you opt in. When a job uses an environment, the subject ends in :environment:<name> instead of the ref, which lets you limit cloud access to jobs that passed that environment's approval.

The login actions of the big clouds do the token exchange for you. These steps were checked with actionlint, not run against a cloud account; the role, identity and project names are placeholders:

The IDs in the Azure example are not secret, so they are variables. On the cloud side, you trust the issuer https://token.actions.githubusercontent.com once, then allow a role (AWS), a federated credential (Azure) or a workload identity pool provider (Google Cloud) to be used by tokens with a specific sub and aud. For AWS, the condition of the role's trust policy for jobs in the production environment of the companion repository would be:

{
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:The-DevOps-Daily@212527617/guide-cicd-demo-2026-10-24@1431822257:environment:production"
  }
}

Match the full subject. A wildcard such as repo:my-org/* lets every repository in the organization, and every branch and pull request in it, assume the role.

Common Mistakes

  • Secrets in the workflow file. Anything in the repository is visible to everyone with read access, and stays in the Git history after you delete it. If it happens, rotate the secret.
  • Printing transformed secrets. Masking only catches the exact value.
  • Secrets in artifacts or caches. Masking does not apply there.
  • One set of credentials for every environment. Use environment secrets, so a staging job cannot read production credentials.
  • Long-lived cloud keys. Use OIDC wherever the provider supports it, and match the full sub claim.
  • Expecting secrets in fork pull requests. They are empty there by design.

Summary

  • env: holds fixed values, configuration variables (vars) hold settings, secrets (secrets) hold credentials.
  • GitHub masks known secret values in logs and job summaries; register other sensitive values with ::add-mask::, and keep secrets out of artifacts and caches.
  • Fork pull requests get no secrets; environment secrets are released only after the environment's rules pass.
  • OIDC replaces stored cloud keys with credentials that expire after about an hour; new repositories have an immutable sub claim with IDs.

In Part 6 you deploy the site to GitHub Pages through a protected environment that needs an approval.

Keep Learning

Updated:

Tags

GitHub ActionsCI/CDContinuous IntegrationContinuous DeploymentAutomationDevOps

Found an issue?