← Blog ·

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 .env files
  • 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.

AI-generated code

Your AI writes the code. Who guards the secrets?

The most common security failure in AI-generated apps is exposed credentials. With Koove, your own assistant stores every token from the CLI — encrypted on your machine, never in the code or the repo.