GitHub Actions secrets are write-only. You can set one, overwrite it, or delete it, but no screen, API endpoint, or gh command gives the value back. Most of the time that is the point. Then someone who set up the deploy leaves, or a repository moves to another organization, or an audit asks what is actually stored in PROD_DB_URL, and the only copy of the value lives inside GitHub.
The standard answer
Search for this and every thread has the same trick. Get a workflow to print the secret in a form the log masker does not recognize.
- run: echo "$SECRET" | sed 's/./& /g'
env:
SECRET: ${{ secrets.PROD_DB_URL }}
The runner replaces every registered secret value in the log with ***. It also masks the obvious encodings. The runner source registers base64, JSON-escaped, URL-escaped and command-line-escaped forms of each secret. Even so, echo "$SECRET" | base64 leaks for some secrets and not others. echo adds a newline, and unless the secret's length is a multiple of three, that newline changes the last characters of the encoding, so the registered base64 string never appears. The answers that always work change the string in ways the masker does not anticipate: inserting spaces, reversing it, or encoding it twice.
It works. The problem is where the value ends up.
What the trick leaves behind
The log keeps the plaintext for the retention period, which is 90 days by default. Organizations can raise it to 400 days on private repositories.
Everyone with read access to the repository can open that log. On a public repository, any signed-in GitHub user can. They can also download the whole log archive from the UI or through the REST API, so a copy can outlive the run.
The fix is to delete the run afterwards. People forget, and deleting does nothing about copies already downloaded. So the value you wanted to read once is now readable by more people than before, for months.
Encrypt inside the runner instead
The runner has the secret in plaintext. The runner can also encrypt. If it encrypts to your public key, the log can contain the result and it does not matter who reads it.
You probably have a suitable key already. GitHub publishes the SSH public keys of every account at https://github.com/<user>.keys, and age accepts ssh-ed25519 and ssh-rsa keys as recipients. It skips ECDSA and hardware-backed sk- keys. You need at least one Ed25519 or RSA key on your account. A throwaway branch with this workflow is enough:
on:
push:
branches: [tmp-recover]
jobs:
recover:
runs-on: ubuntu-latest
# environment: production # needed for environment-level secrets
steps:
- run: |
sudo apt-get update -qq && sudo apt-get install -y -qq age
curl -fsSL https://github.com/YOUR-USER.keys > keys.txt
printf '%s' "$SECRETS" | age -R keys.txt -a
env:
SECRETS: ${{ toJSON(secrets) }}
toJSON(secrets) gives every secret the job can see as one JSON object. Environment secrets are only visible when the job names the environment. That is what the commented line is for. The log shows an armored block that starts with -----BEGIN AGE ENCRYPTED FILE-----. Copy it into a file and decrypt it on your machine:
age -d -i ~/.ssh/id_ed25519 blob.txt
Your private key never goes near GitHub. The ciphertext in the log is useless to anyone else, on a private or a public repository.
Where the tool comes in
recover-secrets is my packaged version of the same idea, with the edges filed down:
- The key can be your
github.com/<user>.keys, yourgithub.com/<user>.gpg, an age recipient, or an RSA public key in PEM. It picks age, GPG, or openssl to match. - The blob goes to the run summary as one line, so you copy it without scrolling through the log.
-
decrypt.shdetects the backend and decrypts with your SSH key, age identity, PEM key, or GPG keyring. -
github_tokenis left out unless you ask for it. - If your organization blocks third-party actions, the same script runs from a plain
run:step.
- uses: p-dim-popov/recover-secrets@v1
with:
secrets-json: ${{ toJSON(secrets) }}
public-key-url: https://github.com/YOUR-USER.keys
What's exposed now
Secret names appear in the log. The runner prints each step's inputs in the step header, so every name in toJSON(secrets) shows there, with the values masked. To list fewer names, pass only the secrets you are recovering, such as {"PROD_DB_URL": ${{ toJSON(secrets.PROD_DB_URL) }}}.
Encryption does not change who can read your secrets. GitHub's documentation says that anyone with write access to a repository has read access to all its secrets, because they can push a workflow like the one above. Environment protection rules and required reviews on workflow changes narrow that. Encrypting to your own key keeps the value out of the log. It does not stop someone who can already run workflows.
When you are done, delete the branch and the run. Your secret values are not in any log in plaintext now.
Top comments (0)