GitHub Actions Secrets: Secure Patterns and the 5 Mistakes Everyone Makes
Almost every repo using GitHub Actions handles CI secrets: deployment tokens, cloud provider keys, database credentials, third-party API keys. And almost every one of them makes the same five mistakes, over and over, even when the secrets.MY_TOKEN syntax is technically correct.
This guide covers concrete patterns for protecting GitHub Actions secrets, and the mistakes that quietly undo them.
Why CI secrets are a different beast
A CI secret doesn't just live in your head or on your laptop — it flows through ephemeral runners, archived logs, reusable workflows, third-party actions, and sometimes pull requests from forks. Each of those is an extra leak surface. GitHub encrypts secrets at rest and masks them in logs, but that doesn't protect you from a misconfigured workflow that prints them, hands them to an untrusted action, or reuses them somewhere it shouldn't.
Mistake 1: hardcoding secrets or committing .env files
The classic: someone pastes an API key straight into the workflow YAML "just to test," or a .env with real credentials ends up in a commit. Once it's in git history, that secret is compromised — deleting it afterward doesn't undo the exposure.
Fix: every secret goes into Settings → Secrets and variables → Actions, never into code:
gh secret set STRIPE_API_KEY --body "sk_live_..." --repo your-org/your-repo
And in the workflow, reference it by name:
steps:
- name: Deploy
env:
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
run: ./deploy.sh
Mistake 2: leaking secrets into logs
GitHub auto-masks a secret's exact literal value if it shows up in output, but that breaks the moment the secret gets transformed — base64-encoded, concatenated into a URL, or printed via set -x inside a script that isn't using the env var you expect.
# Bad: set -x prints every command, including interpolated variables
set -x
curl "https://api.example.com?key=$API_KEY"
Fix: avoid set -x in steps that touch secrets, and if you need to debug, mask explicitly first:
echo "::add-mask::$API_KEY"
curl -s -o /dev/null -w "%{http_code}" "https://api.example.com?key=$API_KEY"
Mistake 3: long-lived credentials instead of OIDC
A lot of pipelines still store AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY as permanent secrets. If that secret leaks, an attacker has standing access until someone manually rotates it.
Fix: use OpenID Connect to mint short-lived credentials on every run, with no static key stored anywhere:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: eu-west-1
AWS, GCP, and Azure all support this flow. There's no secret to rotate or leak because none exists — the token is issued and expires within minutes.
Mistake 4: overly broad GITHUB_TOKEN permissions
By default, many repos inherit broad write permissions for the automatic token issued on every run. If a compromised third-party action with a poisoned dependency runs in your pipeline, it inherits that same scope.
Fix: declare minimal, explicit permissions at the workflow level, and widen them only for the job that actually needs it:
permissions:
contents: read
jobs:
release:
permissions:
contents: write
steps:
- run: gh release create ...
Mistake 5: sharing secrets with fork PRs or across every environment
If you use pull_request_target (needed to give secrets access to fork PRs), you're running untrusted code with access to your production secrets. And if you don't separate environments, a single production secret becomes available to every branch, including a throwaway test one.
Fix: use Environments with required reviewers for production:
jobs:
deploy-prod:
environment:
name: production
url: https://app.yourproduct.com
steps:
- run: ./deploy.sh
GitHub blocks the job until an approved reviewer signs off, and the production secret is never visible to jobs outside that environment.
What GitHub Actions doesn't solve: dev secrets and your AI assistant
Everything above protects the secrets running inside the pipeline. But in the AI-generated code era, a lot of leaks happen earlier: in a local .env, in a prompt your AI assistant used to test an API, in a notebook someone accidentally pushed. That's a different problem from CI, and it deserves its own fix — we cover it in /ai-security.
That's exactly the gap Koove closes: instead of you or your coding assistant pasting real keys into environment files, you store them once via the CLI:
koove set STRIPE_API_KEY sk_live_xxx --env prod
Your code only ever references the name. The secret is encrypted end-to-end (X25519 + AES-256-GCM), the Koove server never sees the plaintext, and the app or an authorized backend decrypts it only after verifying App Attest / Play Integrity plus biometrics — details in /docs/sdk and the architecture writeup at /security. It doesn't replace GitHub Actions secrets for your pipeline, but it closes the flank of dev-time secrets that usually never touch CI at all.
Quick checklist
- No secrets in git history or committed
.envfiles - Explicit, minimal
permissions:on every workflow - OIDC configured for your cloud provider, no static keys
- Environments with required reviewers for any production deploy
- Periodic review of which third-party actions can access which secrets
If you also want to close the gap on secrets your AI assistant or local environment handles, check out /register and give Koove a try.