Module 4: Secrets and Environment Management

2. GitHub Secrets

Overview

GitHub Secrets is GitHub's native mechanism for storing sensitive information — API keys, tokens, passwords — and making it available in GitHub Actions workflows without exposing it in the source code or in the logs.

When you configured ${{ secrets.OPENAI_API_KEY }} in Module 3, you used a GitHub Secret without knowing exactly how it works behind the scenes. This capsule gives you the complete picture: how to create secrets, how GitHub Actions uses them, what protections the system has, and where the limits of those protections are.

What you're going to learn:

  1. Creating repository secrets from the UI and from the CLI
  2. Using secrets in workflows with ${{ secrets.X }}
  3. Understanding the automatic masking in logs
  4. Knowing the three scopes: repository, environment, organization
  5. Identifying the cases where masking does NOT work

What a GitHub Secret is

A secret is a name-value pair stored in encrypted form on GitHub. Once you save it, nobody can see the value — not you, not the repo's admins, not GitHub Support. Only GitHub Actions workflows can access the value during execution.

Properties of a secret

PropertyDetail
EncryptionEncrypted with a libsodium sealed box before it reaches the server
VisibilityOnly the workflow runner can decrypt it during execution
EditingYou can overwrite the value, but you can't see the current one
MaskingGitHub Actions automatically masks the value in the logs
Max size48 KB per secret
Max count100 repository secrets, 100 per environment, 1000 per organization

What masking does and doesn't do

What it DOES do:
  ✅ Replaces the secret's exact value with *** in the logs
  ✅ Works in stdout, stderr, and step outputs
  ✅ It's automatic — you don't need to configure it

What it does NOT do:
  ❌ It doesn't mask transformations of the secret (base64, reversed, partial)
  ❌ It doesn't prevent a script from writing the secret to a file
  ❌ It doesn't protect the secret if you pass it as an argument visible in the logs
  ❌ It doesn't work if the secret is fewer than 4 characters

Creating a repository secret

From GitHub's UI

1. Go to your repository on GitHub
2. Settings → Secrets and variables → Actions
3. Click "New repository secret"
4. Name: OPENAI_API_KEY
5. Secret: sk-proj-abc123... (paste your real API key)
6. Click "Add secret"

After creating it, you'll see the secret's name in the list but never the value. If you need to change it, you overwrite it — you can't see the previous one.

From the CLI with gh

# Create a secret from a direct value
gh secret set OPENAI_API_KEY --body "sk-proj-abc123..."

# Expected output:
# ✓ Set Actions secret OPENAI_API_KEY for owner/repo
# Create a secret from a file
echo "sk-proj-abc123..." > /tmp/api-key.txt
gh secret set OPENAI_API_KEY < /tmp/api-key.txt
rm /tmp/api-key.txt

# Expected output:
# ✓ Set Actions secret OPENAI_API_KEY for owner/repo
# Create a secret interactively (without exposing it in your shell history)
gh secret set OPENAI_API_KEY
# It will ask for the value on stdin — it doesn't stay in your bash history
# List the existing secrets (names only, never values)
gh secret list

# Expected output:
# NAME              UPDATED
# OPENAI_API_KEY    2026-03-08

The safest option

The interactive option (gh secret set NAME without --body) is the safest because:

  • --body "sk-..." leaves the value in your shell history (~/.zsh_history)
  • ❌ The temporary file may not get deleted if the script fails
  • ✅ Interactive input on stdin isn't recorded anywhere

Using secrets in workflows

Basic syntax

# .github/workflows/ci.yml
name: CI with Secrets
on:
  push:
    branches: [main]

jobs:
  ai-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run prompt evaluation
        run: python scripts/evaluate_prompts.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

The secret gets injected as an environment variable only in the step where you define it. Other steps in the same job don't see it.

A secret as an action's input

      - name: Login to Docker registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

GITHUB_TOKEN is a special secret that GitHub generates automatically for every workflow run. You don't need to create it.

A secret in multiple steps

jobs:
  ai-pipeline:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run prompt regression
        run: python scripts/evaluate_prompts.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

      - name: Run integration tests
        run: pytest tests/integration/ -v
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Every step that needs the secret declares it explicitly. If a step doesn't declare OPENAI_API_KEY in its env, it has no access to the value.


Masking in action

What you see in the logs

Suppose your workflow has this:

      - name: Debug (BAD)
        run: echo "The key is $OPENAI_API_KEY"
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

In GitHub Actions' logs you'll see:

The key is ***

GitHub detects that the value of OPENAI_API_KEY appears in the output and replaces it with ***. This works automatically — you don't need to configure it.

When masking fails

Masking has limits. These situations can expose a secret:

Case 1: Transforming the value

      - name: Encode secret (DANGER)
        run: echo "$OPENAI_API_KEY" | base64
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

The output of base64 is a transformation of the secret. GitHub masks sk-proj-abc123... but it does NOT mask c2stcHJvai1hYmMxMjMu... (the base64 version). The transformed value appears in the logs in plain text.

Case 2: A partial secret

      - name: Partial echo (DANGER)
        run: echo "First 5 chars: ${OPENAI_API_KEY:0:5}"
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

${OPENAI_API_KEY:0:5} extracts the first 5 characters. If the substring doesn't match the secret's full value exactly, GitHub doesn't mask it.

Case 3: The secret written to a downloadable file

      - name: Write to file (DANGER)
        run: echo "$OPENAI_API_KEY" > /tmp/key.txt
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

      - name: Upload as artifact
        uses: actions/upload-artifact@v4
        with:
          name: debug-info
          path: /tmp/key.txt

The file contains the secret in plain text. Masking only works in logs, not in files. If you upload the file as an artifact, anyone with access to the repo can download it.


The golden rule: never echo secrets

# ❌ NEVER
- run: echo ${{ secrets.OPENAI_API_KEY }}
- run: echo "Key: $OPENAI_API_KEY"
- run: printenv | grep API

# ✅ ALWAYS
- run: python scripts/evaluate_prompts.py
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

The correct pattern is: pass the secret as an environment variable and let the script read it with os.environ. Never print it, never log it, never write it to a file.

Verifying that a secret exists without exposing its value

      - name: Verify secrets are set
        run: |
          if [ -z "$OPENAI_API_KEY" ]; then
            echo "ERROR: OPENAI_API_KEY is not set"
            exit 1
          fi
          echo "OPENAI_API_KEY is set (length: ${#OPENAI_API_KEY} chars)"
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Output in the logs:

OPENAI_API_KEY is set (length: 51 chars)

You can verify that the secret exists and has a reasonable length without exposing the value.


The three scopes of secrets

1. Repository Secrets

Scope:    A specific repository
Access:   Every workflow in the repository
Creation: Settings → Secrets → Actions → New repository secret
Limit:    100 secrets

Use repository secrets for keys specific to a project. If you only have one AI project, this is enough.

2. Environment Secrets

Scope:    An environment inside a repository (staging, production)
Access:   Only workflows that declare that environment
Creation: Settings → Environments → [name] → Add secret
Limit:    100 secrets per environment

Environment secrets take priority over repository secrets with the same name. If you define OPENAI_API_KEY at the repo level and also in the "production" environment, the workflow that uses the "production" environment will see the environment secret's value.

3. Organization Secrets

Scope:    Every repository in the organization (or selected repositories)
Access:   Workflows of the configured repositories
Creation: Organization Settings → Secrets → Actions → New organization secret
Limit:    1000 secrets

Use organization secrets for keys shared across multiple projects. If your organization has 10 repos that use OpenAI, you can define the key once at the organization level.

Precedence

If secrets with the same name exist in multiple scopes:

Environment > Repository > Organization

An environment secret "OPENAI_API_KEY" overrides
a repository secret "OPENAI_API_KEY" which overrides
an organization secret "OPENAI_API_KEY".

GITHUB_TOKEN: the automatic secret

Every workflow run automatically receives a special secret called GITHUB_TOKEN. You don't need to create it — GitHub generates it for each run.

      - name: Create comment on PR
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'AI Quality Gate passed!'
            })

GITHUB_TOKEN permissions

PermissionDefault (PRs from forks)Default (same repo)
contentsreadwrite
issuesreadwrite
pull-requestsreadwrite
packagesreadwrite

You can restrict the permissions explicitly:

permissions:
  contents: read
  pull-requests: write
  packages: none

Always declare the minimum necessary permissions. Don't leave the broad defaults.


A secret verification script

You can use this script in your pipeline to verify that all the necessary secrets are configured:

#!/usr/bin/env python3
"""
Verify that the required secrets are available as environment variables.
It doesn't print values — it only verifies existence and format.
"""
import os
import sys


REQUIRED_SECRETS = {
    "OPENAI_API_KEY": {
        "prefix": "sk-",
        "min_length": 20,
    },
}

OPTIONAL_SECRETS = {
    "ANTHROPIC_API_KEY": {
        "prefix": "sk-ant-",
        "min_length": 20,
    },
}


def verify_secret(name: str, config: dict) -> dict:
    value = os.environ.get(name, "")

    if not value:
        return {"name": name, "status": "MISSING", "detail": "Not set"}

    if config.get("prefix") and not value.startswith(config["prefix"]):
        return {
            "name": name,
            "status": "INVALID_FORMAT",
            "detail": f"Expected prefix '{config['prefix']}', got '{value[:5]}...'",
        }

    if config.get("min_length") and len(value) < config["min_length"]:
        return {
            "name": name,
            "status": "TOO_SHORT",
            "detail": f"Expected >= {config['min_length']} chars, got {len(value)}",
        }

    return {
        "name": name,
        "status": "OK",
        "detail": f"Set ({len(value)} chars)",
    }


def main() -> int:
    print("Verifying required secrets...")
    all_ok = True

    for name, config in REQUIRED_SECRETS.items():
        result = verify_secret(name, config)
        status_icon = "PASS" if result["status"] == "OK" else "FAIL"
        print(f"  [{status_icon}] {result['name']}: {result['detail']}")
        if result["status"] != "OK":
            all_ok = False

    print("\nVerifying optional secrets...")
    for name, config in OPTIONAL_SECRETS.items():
        result = verify_secret(name, config)
        status_icon = "PASS" if result["status"] == "OK" else "SKIP"
        print(f"  [{status_icon}] {result['name']}: {result['detail']}")

    if all_ok:
        print("\nAll required secrets verified.")
        return 0

    print("\nSome required secrets are missing or invalid.")
    return 1


if __name__ == "__main__":
    sys.exit(main())

Expected output (locally, without secrets)

Verifying required secrets...
  [FAIL] OPENAI_API_KEY: Not set

Verifying optional secrets...
  [SKIP] ANTHROPIC_API_KEY: Not set

Some required secrets are missing or invalid.

Expected output (in CI, with the secrets configured)

Verifying required secrets...
  [PASS] OPENAI_API_KEY: Set (51 chars)

Verifying optional secrets...
  [SKIP] ANTHROPIC_API_KEY: Not set

All required secrets verified.

In the workflow

      - name: Verify secrets
        run: python scripts/verify_secrets.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

Troubleshooting

"The secret is configured but the script says it doesn't exist"

Cause: The secret isn't passed as env in the step that needs it.

Solution: Every step must explicitly declare the secrets it uses:

      # ❌ The secret isn't available here
      - name: Run script
        run: python scripts/evaluate_prompts.py

      # ✅ The secret is available as an environment variable
      - name: Run script
        run: python scripts/evaluate_prompts.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

"The secret shows an empty string"

Cause: The secret's name in the workflow doesn't exactly match the name in GitHub Settings.

Solution: Verify the exact name. Names are case-sensitive:

# ❌ If the secret is called "OPENAI_API_KEY"
env:
  OPENAI_API_KEY: ${{ secrets.openai_api_key }}  # Wrong case

# ✅ Exact case
env:
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

"The logs show *** in unexpected places"

Cause: Masking is aggressive — if the secret's value appears anywhere in the output, it gets masked. If your API key contains a common substring (like "test"), every occurrence of "test" gets masked.

Solution: Use API keys with unique prefixes (like OpenAI's sk-proj-...). If the problem persists, verify that you're not using a value that's too short as a secret.

"The workflow works on main but fails on PRs from forks"

Cause: For security, repository secrets are NOT available in workflows triggered by PRs from forks. This prevents an attacker from creating a fork, modifying the workflow to print the secrets, and opening a PR.

Solution: For checks that need secrets, use pull_request_target instead of pull_request (with caution), or limit the checks with secrets to pushes to branches in the same repo:

on:
  push:
    branches: [main, "feature/**"]
  pull_request:
    branches: [main]

jobs:
  ai-checks:
    if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    steps:
      - run: python scripts/evaluate_prompts.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

"I need the same secret in 5 different steps"

Cause: Repeating env in every step is verbose but it's the correct pattern.

Solution: You can define env at the job level so that every step in the job has access:

jobs:
  ai-checks:
    runs-on: ubuntu-latest
    env:
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
    steps:
      - run: python scripts/step1.py   # It has access
      - run: python scripts/step2.py   # It has access
      - run: python scripts/step3.py   # It has access

This is convenient but less secure — every step sees the secret, even the ones that don't need it.


Exercises

Exercise 1: Create and use a repository secret

Configure a TEST_SECRET secret with the value hello-from-secret-123 in your repository. Create a workflow that verifies the secret exists without printing it.

See solution
# Create the secret
gh secret set TEST_SECRET --body "hello-from-secret-123"
# .github/workflows/test-secrets.yml
name: Test Secrets
on: workflow_dispatch

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - name: Check secret exists
        run: |
          if [ -z "$TEST_SECRET" ]; then
            echo "TEST_SECRET is NOT set"
            exit 1
          fi
          echo "TEST_SECRET is set (length: ${#TEST_SECRET} chars)"
        env:
          TEST_SECRET: ${{ secrets.TEST_SECRET }}

Expected output:

TEST_SECRET is set (length: 23 chars)

The value has 23 characters (hello-from-secret-123). The value isn't printed, only its length.

Exercise 2: Demonstrate masking

Create a workflow that tries to print a secret in three different ways. Predict what you'll see in the logs before running it.

See solution
# .github/workflows/masking-demo.yml
name: Masking Demo
on: workflow_dispatch

jobs:
  demo:
    runs-on: ubuntu-latest
    steps:
      - name: Direct echo
        run: echo "Value: $MY_SECRET"
        env:
          MY_SECRET: ${{ secrets.TEST_SECRET }}

      - name: Length check
        run: echo "Length: ${#MY_SECRET}"
        env:
          MY_SECRET: ${{ secrets.TEST_SECRET }}

      - name: First char check
        run: echo "Starts with: ${MY_SECRET:0:1}"
        env:
          MY_SECRET: ${{ secrets.TEST_SECRET }}

Expected output in the logs:

# Step 1: Direct echo
Value: ***

# Step 2: Length check
Length: 23

# Step 3: First char check
Starts with: h

The direct echo gets masked. The length doesn't get masked (it's not the value). The first character (h) probably doesn't get masked because a single character doesn't match the full value. This demonstrates that masking has limits.

Exercise 3: A multi-provider verification script

Write a Python script that verifies the existence and format of secrets for three AI providers: OpenAI, Anthropic, and Google AI. Each provider has a different key prefix.

See solution
#!/usr/bin/env python3
"""Secret verification for multiple AI providers."""
import os
import sys


PROVIDERS = {
    "OPENAI_API_KEY": {
        "provider": "OpenAI",
        "prefix": "sk-",
        "min_length": 20,
        "required": True,
    },
    "ANTHROPIC_API_KEY": {
        "provider": "Anthropic",
        "prefix": "sk-ant-",
        "min_length": 30,
        "required": False,
    },
    "GOOGLE_AI_API_KEY": {
        "provider": "Google AI",
        "prefix": "AI",
        "min_length": 30,
        "required": False,
    },
}


def check_provider(env_name: str, config: dict) -> dict:
    value = os.environ.get(env_name, "")
    result = {
        "env_name": env_name,
        "provider": config["provider"],
        "required": config["required"],
    }

    if not value:
        result["status"] = "missing"
        result["message"] = "Not configured"
        return result

    if not value.startswith(config["prefix"]):
        result["status"] = "invalid"
        result["message"] = f"Bad prefix (expected {config['prefix']}...)"
        return result

    if len(value) < config["min_length"]:
        result["status"] = "suspicious"
        result["message"] = f"Too short ({len(value)} < {config['min_length']})"
        return result

    result["status"] = "ok"
    result["message"] = f"Valid ({len(value)} chars)"
    return result


def main() -> int:
    print("AI Provider Secret Verification")
    print("=" * 50)

    has_errors = False
    for env_name, config in PROVIDERS.items():
        result = check_provider(env_name, config)

        if result["status"] == "ok":
            icon = "OK"
        elif result["required"]:
            icon = "FAIL"
            has_errors = True
        else:
            icon = "SKIP"

        print(f"  [{icon}] {result['provider']} ({env_name}): {result['message']}")

    print("=" * 50)
    if has_errors:
        print("Required secrets missing. Pipeline cannot continue.")
        return 1
    print("All required secrets available.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Expected output (only OpenAI configured):

AI Provider Secret Verification
==================================================
  [OK] OpenAI (OPENAI_API_KEY): Valid (51 chars)
  [SKIP] Anthropic (ANTHROPIC_API_KEY): Not configured
  [SKIP] Google AI (GOOGLE_AI_API_KEY): Not configured
==================================================
All required secrets available.

Exercise 4: A workflow with secret verification as the first step

Create a complete workflow that: (1) verifies the secrets exist, (2) runs the AI checks only if the verification passes, (3) generates a report as an artifact.

See solution
# .github/workflows/secure-ai-checks.yml
name: Secure AI Checks
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  verify-and-run:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Verify required secrets
        id: verify
        run: |
          python scripts/verify_secrets.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

      - name: Run prompt evaluation
        if: steps.verify.outcome == 'success'
        run: |
          python scripts/evaluate_prompts.py \
            --baseline baselines/prompt-baseline.json \
            --output results/evaluation.json
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

      - name: Run cost estimation
        if: steps.verify.outcome == 'success'
        run: |
          python scripts/estimate_cost.py \
            --output results/cost-report.json

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: ai-check-results
          path: results/
          retention-days: 14

The flow:

  1. It verifies that OPENAI_API_KEY exists and has a valid format
  2. Only if the verification passes does it run the AI checks
  3. The results always get uploaded as an artifact (even if a check fails)

Summary

  • GitHub Secrets stores encrypted values accessible only during a workflow's execution
  • Three scopes: repository (one repo), environment (one environment), organization (every repo)
  • Precedence: environment > repository > organization
  • Automatic masking replaces the secret's exact value with *** in the logs
  • The limits of masking: transformations (base64), substrings, and files do NOT get masked
  • The golden rule: never echo a secret — pass it as env and read it with os.environ
  • GITHUB_TOKEN is automatic — you don't need to create it, but you do need to restrict its permissions
  • Secrets in forks: for security, secrets are NOT available in PRs from forked repositories
  • Programmatic verification: use a script that validates existence and format without exposing values

Additional resources

  1. GitHub Actions Encrypted Secrets — Complete official documentation on secrets
  2. GitHub Actions Security Hardening — Security guide for workflows
  3. GitHub CLI — gh secret — Reference for the gh secret command
  4. GITHUB_TOKEN permissions — The automatic token's permissions
  5. libsodium sealed box — The encryption algorithm GitHub uses for secrets
  6. GitGuardian Blog — Secret Detection — Articles about detecting exposed secrets