Send a secret from GitHub Actions to a person with a one-time link

A working workflow step that hands a generated password to one person and keeps it out of the job log. Send a secret from GitHub Actions as a one-time link.

The Evergist team · · 6 min read

Short answer: in the job, pipe the generated value into npx -y evergist create --views 1 --expires 1h --json, mask the link, and post the link to a private channel or a direct message. The runner encrypts the value, so the job log and our server never hold it. The link is itself a secret, so it must stay out of the log too.

Most CI guides cover secrets going in. Store them in the platform, inject them as environment variables. This post covers the other direction. A job rotates a database password, issues a temporary token, or builds a kubeconfig, and a person needs the result.

Why not print it and let GitHub mask it?

Because masking is a filter on log output, and GitHub says it can miss. It exists to catch accidents. It can't deliver a value to someone.

What GitHub's docs say:

  • ::add-mask:: "prevents a string or variable from being printed in the log", and "each masked word separated by whitespace is replaced with the * character" (workflow commands). A masked value is unreadable to the person who needs it as well.
  • "Because there are multiple ways a secret value can be transformed, automatic redaction is not guaranteed" (secure use reference). The same page says redaction "largely relies on finding an exact match for the specific secret value", so an encoded or reformatted copy can get through.
  • You have to register the mask before the value is printed. The docs warn to "register the secret with 'add-mask' before outputting it in the build logs".
  • Viewing, searching, and downloading run logs takes read access to the repository (workflow run logs). Assume everyone who can read the repo can read the log.

So the log is the wrong place for the value, masked or not. The job needs a separate path to one person.

How do I send a generated password from GitHub Actions to a person?

Create a one-view note on the runner and send only the link, through a channel the right person reads.

name: Rotate staging database password
on: workflow_dispatch

jobs:
  rotate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Rotate and hand off
        env:
          NOTIFY_WEBHOOK_URL: ${{ secrets.NOTIFY_WEBHOOK_URL }}
        run: |
          set -euo pipefail

          new_password=$(npx -y evergist password)
          echo "::add-mask::$new_password"

          # Apply $new_password here: ALTER ROLE, your cloud CLI, and so on.

          link=$(printf '%s' "$new_password" \
            | npx -y evergist create --views 1 --expires 1h --json \
            | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0, "utf8")).url)')
          echo "::add-mask::$link"

          LINK="$link" node -e 'process.stdout.write(JSON.stringify({ text: "Staging DB password rotated. Opens once, expires in 1 hour: " + process.env.LINK }))' \
            | curl -fsS -H 'content-type: application/json' --data @- "$NOTIFY_WEBHOOK_URL"

What each part does:

  1. npx -y evergist password generates the value. Any generator works. The assignment captures it, so nothing prints.
  2. ::add-mask:: registers the password before any later command could echo it. This follows GitHub's own "masking a generated output" example.
  3. create --views 1 --expires 1h --json encrypts on the runner with AES-256-GCM and uploads ciphertext. The JSON has url, id, expiresAt, maxViews, and deleteToken. The script keeps url and drops the rest.
  4. The second ::add-mask:: covers the link, in case a later step prints it.
  5. The last command posts the link to a webhook whose URL is a repository secret. The example sends JSON with a text field. Change the payload to whatever your chat tool or pager expects.

Keep all of it in one step. GitHub's docs note that once you mask a value "you won't be able to set that value as an output", and for passing a secret between jobs they recommend a secret store. A single step avoids both problems.

For a file such as a kubeconfig, pass the path and never cat it:

link=$(npx -y evergist create kubeconfig.yaml --views 1 --expires 1h --json \
  | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0, "utf8")).url)')

GitHub's secure use reference warns that structured data "can cause secret redaction within logs to fail", and a kubeconfig is structured data. Not printing it is the only safe rule. Notes hold text up to 512 KiB.

Somewhere only the intended person reads. The link carries the decryption key after the #, so whoever opens it first reads the note. That is the honest limit of every one-time link.

Destination Use it? Why
Direct message to one person Yes One reader
Small private channel, such as the on-call channel Yes Few readers, and the first to open it gets the value
Email to one person Yes Same as a DM, slower
The job log (echo "$link") No Everyone with read access to the repo can see it
Job summary (GITHUB_STEP_SUMMARY) No It shows on the run page, and GitHub's docs say removing it means deleting the whole workflow run
PR or issue comment No Same audience as the repo, and it stays in the thread
A public or company-wide channel No Too many first readers

One view and a one-hour expiry limit the damage when the link goes somewhere it shouldn't. They don't undo it. If the wrong person opens it first, they have the password.

The upside is that a stolen read is visible. The intended recipient opens the link and gets "not found" instead of a password. Tell people that a dead link means "rotate again", not "ask for a new link".

A preview fetch of the page doesn't spend the view. In a browser, a view-limited note waits for a click on "Open the note" before it requests the ciphertext. Do link previews burn one-time links goes through the cases.

How do I do this outside GitHub Actions?

The same script works in a cron job, a provisioning script, or another CI system. It needs Node.js 20 or newer and outbound HTTPS to evergist.com.

#!/usr/bin/env bash
set -euo pipefail

token=$(./issue-temp-token.sh)

link=$(printf '%s' "$token" \
  | npx -y evergist create --views 1 --expires 1h --json \
  | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0, "utf8")).url)')
unset token

LINK="$link" node -e 'process.stdout.write(JSON.stringify({ text: "Temp token, opens once, expires in 1 hour: " + process.env.LINK }))' \
  | curl -fsS -H 'content-type: application/json' --data @- "$NOTIFY_WEBHOOK_URL"

Three things to watch in any runner:

  • Don't run the script with set -x. Bash tracing prints every expanded command, including the value and the link.
  • Pass the value on stdin or as a file path. Command-line arguments show up in process lists.
  • Let a failed create fail the job. The CLI exits non-zero with a reason on stderr. The API allows 10 creates per minute and 120 per hour from one network, so a busy shared runner can hit rate_limited.

If you'd rather not pull from npm in CI, pin the single-file CLI. Share secrets from the terminal shows how.

Questions

How do I keep a secret out of CI logs?

Don't print it. Capture it in a variable, register it with ::add-mask:: as a backstop, and pass it to other commands on stdin. GitHub's docs say redaction is not guaranteed when a value is transformed, so masking alone is not a plan.

Does Evergist see the password my job generated?

No. The CLI encrypts on the runner and uploads ciphertext. The key exists on the runner and in the link. We publish what the server stores.

Do I need an API key or an account in CI?

No. There is no account and no API key, so there is no extra secret to add to the repository for the create step. You only need a secret for wherever the link goes, such as a webhook URL.

Can the recipient be another job or an AI agent?

Yes. npx -y evergist read "<link>" prints the text and uses the view. An agent that reads the note sees the value, the same as a person would. How AI agents share secrets covers that handoff. For a client in another language, our post on the self-destructing note API has a Web Crypto example, and the REST API docs have the reference.