<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Petar Popov</title>
    <description>The latest articles on DEV Community by Petar Popov (@pdimpopov).</description>
    <link>https://dev.to/pdimpopov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4150658%2F5cbca53d-6b0b-4cf4-a2a9-54351c59aad0.png</url>
      <title>DEV Community: Petar Popov</title>
      <link>https://dev.to/pdimpopov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pdimpopov"/>
    <language>en</language>
    <item>
      <title>Getting a GitHub Actions secret back without leaving it in the log</title>
      <dc:creator>Petar Popov</dc:creator>
      <pubDate>Tue, 29 Sep 2026 17:51:55 +0000</pubDate>
      <link>https://dev.to/pdimpopov/getting-a-github-actions-secret-back-without-leaving-it-in-the-log-2ap9</link>
      <guid>https://dev.to/pdimpopov/getting-a-github-actions-secret-back-without-leaving-it-in-the-log-2ap9</guid>
      <description>&lt;p&gt;GitHub Actions secrets are write-only. You can set one, overwrite it, or delete it, but no screen, API endpoint, or &lt;code&gt;gh&lt;/code&gt; 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 &lt;code&gt;PROD_DB_URL&lt;/code&gt;, and the only copy of the value lives inside GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard answer
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "$SECRET" | sed 's/./&amp;amp; /g'&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PROD_DB_URL }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner replaces every registered secret value in the log with &lt;code&gt;***&lt;/code&gt;. It also masks the obvious encodings. The &lt;a href="https://github.com/actions/runner/blob/main/src/Runner.Common/HostContext.cs" rel="noopener noreferrer"&gt;runner source&lt;/a&gt; registers base64, JSON-escaped, URL-escaped and command-line-escaped forms of each secret. Even so, &lt;code&gt;echo "$SECRET" | base64&lt;/code&gt; leaks for some secrets and not others. &lt;code&gt;echo&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;It works. The problem is where the value ends up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the trick leaves behind
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypt inside the runner instead
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You probably have a suitable key already. GitHub publishes the SSH public keys of every account at &lt;code&gt;https://github.com/&amp;lt;user&amp;gt;.keys&lt;/code&gt;, and &lt;a href="https://github.com/FiloSottile/age" rel="noopener noreferrer"&gt;age&lt;/a&gt; accepts &lt;code&gt;ssh-ed25519&lt;/code&gt; and &lt;code&gt;ssh-rsa&lt;/code&gt; keys as recipients. It skips ECDSA and hardware-backed &lt;code&gt;sk-&lt;/code&gt; keys. You need at least one Ed25519 or RSA key on your account. A throwaway branch with this workflow is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;tmp-recover&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;recover&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# environment: production  # needed for environment-level secrets&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sudo apt-get update -qq &amp;amp;&amp;amp; sudo apt-get install -y -qq age&lt;/span&gt;
          &lt;span class="s"&gt;curl -fsSL https://github.com/YOUR-USER.keys &amp;gt; keys.txt&lt;/span&gt;
          &lt;span class="s"&gt;printf '%s' "$SECRETS" | age -R keys.txt -a&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;SECRETS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ toJSON(secrets) }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;toJSON(secrets)&lt;/code&gt; 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 &lt;code&gt;-----BEGIN AGE ENCRYPTED FILE-----&lt;/code&gt;. Copy it into a file and decrypt it on your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;age &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/id_ed25519 blob.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your private key never goes near GitHub. The ciphertext in the log is useless to anyone else, on a private or a public repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tool comes in
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/p-dim-popov/recover-secrets" rel="noopener noreferrer"&gt;recover-secrets&lt;/a&gt; is my packaged version of the same idea, with the edges filed down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The key can be your &lt;code&gt;github.com/&amp;lt;user&amp;gt;.keys&lt;/code&gt;, your &lt;code&gt;github.com/&amp;lt;user&amp;gt;.gpg&lt;/code&gt;, an age recipient, or an RSA public key in PEM. It picks age, GPG, or openssl to match.&lt;/li&gt;
&lt;li&gt;The blob goes to the run summary as one line, so you copy it without scrolling through the log.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;decrypt.sh&lt;/code&gt; detects the backend and decrypts with your SSH key, age identity, PEM key, or GPG keyring.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;github_token&lt;/code&gt; is left out unless you ask for it.&lt;/li&gt;
&lt;li&gt;If your organization blocks third-party actions, the same script runs from a plain &lt;code&gt;run:&lt;/code&gt; step.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;p-dim-popov/recover-secrets@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secrets-json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ toJSON(secrets) }}&lt;/span&gt;
    &lt;span class="na"&gt;public-key-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/YOUR-USER.keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's exposed now
&lt;/h2&gt;

&lt;p&gt;Secret names appear in the log. The runner prints each step's inputs in the step header, so every name in &lt;code&gt;toJSON(secrets)&lt;/code&gt; shows there, with the values masked. To list fewer names, pass only the secrets you are recovering, such as &lt;code&gt;{"PROD_DB_URL": ${{ toJSON(secrets.PROD_DB_URL) }}}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;When you are done, delete the branch and the run. Your secret values are not in any log in plaintext now.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>security</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
