You build a small automation that posts to Discord when your website goes down. To make it work, you paste the webhook URL into your script. It works on the first try, so you commit it and move on.
Months later, you make the repo public to share it. Now anyone can read that URL, and anyone can post to your Discord channel.
Secrets are one of those things that feel like a small detail until they aren't. Here are eight common ways they leak in GitHub Actions, and how to avoid each one.
What counts as a secret?
Anything that gives access to something. That includes API keys, passwords, tokens and webhook URLs.
If you're not sure whether something is a secret, ask: what could someone do if they had this? If the answer is "post as me," "use my account" or "spend my quota," treat it as a secret.
(If API keys and webhooks are still a bit fuzzy, I've written a beginner guide to what an API is and one on how webhooks work.)
1. Hardcoding the secret in your files
This is the classic. The key sits right in your script or workflow file, and it stays in your git history even if you delete it later.
Store it as a repository secret instead. In your repo, open Settings, then Secrets and variables, then Actions, and add it there. Then read it in your workflow:
- name: Send alert
env:
WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK }}
run: python alert.py
Your script reads WEBHOOK_URL from the environment, so the real value never appears in the code.
Passing it through env is also cleaner than pasting ${{ secrets.X }} directly into a command. It avoids quoting problems and keeps the value out of the command text.
2. Printing it in the logs
You're debugging. Something isn't working, so you add a line to check the value:
run: echo "Token is $API_TOKEN"
GitHub tries to hide secrets in logs, but that only helps if the secret matches exactly. Debug commands that print all environment variables can expose more than you meant to.
Remove debug lines before you commit. If you need to check that a secret is set, print whether it's empty, not what it contains.
3. Trusting log masking to catch everything
GitHub hides secret values it recognizes in your logs. But GitHub's own documentation says automatic redaction isn't guaranteed, because a value can be changed in many ways before it's printed.
For example, if your script encodes the secret or cuts it into pieces, the result may not match the original, and it may show up in plain text.
Two habits help here:
- Store each secret as its own single value. Don't store a whole block of JSON or YAML as one secret, because redaction can miss parts of it.
- If your workflow builds a sensitive value on its own, tell GitHub to hide it:
- run: echo "::add-mask::$CUSTOMER_ID"
4. Using a key with more power than the job needs
Say your automation only needs to post messages. If you give it a key that can also delete things and change settings, a leak becomes far more damaging.
Use the smallest permission that works, and use a separate key for each project so you can cancel one without breaking the rest.
Where the service allows it, use a restricted key instead of your main login. That's why bots for Bluesky use an app password, not the account password. I covered this in my Bluesky automation bot guide.
Also remember that anyone with write access to your repository can read its secrets. Think before you add collaborators.
5. Leaving GITHUB_TOKEN with too much access
Every workflow run gets a built-in token called GITHUB_TOKEN. It lets the workflow talk to your repository, and it can have more permissions than the job needs.
GitHub recommends making read-only access the default and adding more only where a job needs it. You can set this at the top of your workflow file:
permissions:
contents: read
If one job needs to write something, give that job the extra permission and leave the rest alone.
6. Running risky workflows from pull requests
This one matters most if your repo is public or forkable.
On a normal pull_request trigger, workflows from forks don't get your secrets. That's a good default. The trouble starts when people switch to pull_request_target, which does run with access to secrets.
GitHub's advice is to avoid pull_request_target unless you need it. If you do use it, don't check out and run code from an untrusted pull request. A stranger's code running with your secrets is the situation to avoid.
For a small personal automation, the simplest option is not to trigger workflows from outside pull requests at all.
7. Pasting untrusted text straight into a command
Some workflows react to issues or pull requests and use their titles or descriptions in a script:
- run: echo "New issue: ${{ github.event.issue.title }}"
The problem is that anyone can write an issue title. If a title contains shell commands, they may end up running on your runner, with access to your secrets.
The safer way is to put the value in an environment variable first:
- env:
TITLE: ${{ github.event.issue.title }}
run: echo "New issue: $TITLE"
Now the title is treated as plain text, not as part of the command.
8. Using third-party actions without a second look
When you write uses: someone/some-action@v1, you're running someone else's code. If you pass it a secret, it can use that secret.
A few habits help:
- Prefer actions from GitHub itself or from well-known, maintained projects.
- Read what the action does before you give it a secret.
- Pin it to a full commit SHA instead of a version tag. A tag can be moved to different code later, but a commit can't.
- uses: some-owner/some-action@<full-commit-sha> # v1.2.3
Leave a comment with the version number so you remember what the SHA means.
What to do if a secret leaks
Act in this order.
- Replace the secret first. Create a new key or token, put it in GitHub Secrets, and cancel the old one at the source. This is the step that actually protects you.
- Delete the workflow logs that show the value, if it appeared in a log. GitHub's advice is to delete the log and rotate the secret.
- Check for misuse. Look at the service's activity or usage page for anything you don't recognize.
- Then clean up your repo, if the secret was committed. Removing it from history is good, but don't rely on it. Once something has been public, assume someone copied it.
Deleting the file or the commit is not enough on its own. The old key still works until you cancel it.
A quick checklist
Before you push a new workflow, run through this:
- No secrets in the code or in workflow files
- No debug lines that print secrets
- Each secret is a single value, not a block of JSON
- Each key has only the permissions it needs
-
permissions: contents: readis set unless the job needs more - No
pull_request_targetunless you really need it - Untrusted text goes through
env, not straight intorun - Third-party actions are trusted and pinned
- Secret scanning and push protection are turned on in your repository's security settings
Quick answers
Are GitHub Actions secrets safe?
They're encrypted and hidden in logs, but they're only as safe as your workflow. Printing them, passing them to untrusted code, or giving collaborators write access can all expose them.
Can people see my secrets after I save them?
No. You can replace a secret but not read it back. However, anyone with write access to the repo can use it in a workflow.
Do forks get my secrets?
Not on a regular pull_request trigger. They can be exposed by workflows that run with more privileges, like pull_request_target, so use those carefully.
How often should I rotate secrets?
Whenever someone who had access leaves, whenever you suspect a leak, and every so often for keys that matter. Set a reminder so it doesn't depend on memory.
The short version
Most secret leaks aren't clever attacks. They're a debug line, a pasted key, or a key with more power than it needed.
Keep secrets out of your files, keep them out of your logs, and give each one only the access it needs. Do that, and you've covered most of the risk.
If you'd like more practical guides on small automations, you can find them on Procwire.
Top comments (0)