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:
- Creating repository secrets from the UI and from the CLI
- Using secrets in workflows with
${{ secrets.X }} - Understanding the automatic masking in logs
- Knowing the three scopes: repository, environment, organization
- 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
| Property | Detail |
|---|---|
| Encryption | Encrypted with a libsodium sealed box before it reaches the server |
| Visibility | Only the workflow runner can decrypt it during execution |
| Editing | You can overwrite the value, but you can't see the current one |
| Masking | GitHub Actions automatically masks the value in the logs |
| Max size | 48 KB per secret |
| Max count | 100 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
| Permission | Default (PRs from forks) | Default (same repo) |
|---|---|---|
contents | read | write |
issues | read | write |
pull-requests | read | write |
packages | read | write |
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:
- It verifies that
OPENAI_API_KEYexists and has a valid format - Only if the verification passes does it run the AI checks
- 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
echoa secret — pass it asenvand read it withos.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
- GitHub Actions Encrypted Secrets — Complete official documentation on secrets
- GitHub Actions Security Hardening — Security guide for workflows
- GitHub CLI —
gh secret— Reference for thegh secretcommand - GITHUB_TOKEN permissions — The automatic token's permissions
- libsodium sealed box — The encryption algorithm GitHub uses for secrets
- GitGuardian Blog — Secret Detection — Articles about detecting exposed secrets