Skip to content
syntaqx
6 min read

Passing Secrets to Manual GitHub Actions Workflows

workflow_dispatch has no secret input type, and add-mask runs too late to hide one. A one-time link gets a secret into the workflow, masked, without leaking it.

The short version: a manually triggered (workflow_dispatch) workflow can't take a secret as an input. GitHub only allows boolean, choice, number, environment and string input types, and anything you type is in the logs before ::add-mask:: can hide it. My fix is to pass a one-time link instead of the value: the workflow fetches the secret, masks it, then uses it, and the link is dead by the time anyone reads the log.

I've been using GitHub Actions as my primary CI & CD tool for a while now, and I've been very happy with it. It's easy to use, reasonably flexible with the way actions are created, and has a massive marketplace which makes things all too easy to get what I need.

One of the features that I've been using a lot lately is manually triggered workflows, allowing me to create self-service workflows that can be triggered on demand, saving us from repetitive tasks; one automation at a time.

A simple example of these self-service workflow would be one that allows engineers to update their own environment variables for a service. The team can simply go to our ops repositories Actions, and manually trigger the workflow with the service name, environment, key, and value as inputs:

name: Upsert Environment Variable

on:
  workflow_dispatch:
    inputs:
      service:
        description: 'Service Name'
        required: true
        type: choice
        options:
          - 'service-a'
          - 'service-b'
          - 'service-c'
      environment:
        description: 'Environment'
        required: true
        type: choice
        options:
          - 'staging'
          - 'production'
      key:
        description: 'Key'
        required: true
        type: string
      value:
        description: 'Value'
        required: true
        type: string

jobs:

  upsert:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # .. omitted ..

And that's it! No more asking operations to update your environment variables.

Automation, done.

Except... not all environment variables are created equal.

Secrets#

In order for this workflow to be successful, we had to make sure that secret values were not exposed in workflow logs, exposing the value to anyone with access to the repository.. and defeating the win of self-service.

Luckily, GitHub supports masking a value in a log, so let's just mask the input value and be done with it!

Failed Attempt

- name: Mask secret value securely
  run: |
    echo "SECRET_VALUE=${{ github.event.inputs.secret_value }}" >> $GITHUB_ENV
    echo "::add-mask::${{ github.event.inputs.secret_value }}"
echo "SECRET_VALUE=DONT SHOW THE SECRET" >> $GITHUB_ENV
echo "::add-mask::DONT SHOW THE SECRET"

... and another

  env:
    SECRET_KEY: ${{ github.event.inputs.key }}
    SECRET_VALUE: ${{ github.event.inputs.secret_value }}
  steps:
    - run: echo "::add-mask::$SECRET_VALUE"
echo "::add-mask::$SECRET_VALUE"
shell: /usr/bin/bash -e {0}
env:
  SECRET_KEY: my-secret-key
  SECRET_VALUE: SHHH IM SECRET

...and on and on and on

All in all, I spent probably 2 hours trying to figure out how to mask the input value, but no matter what I did, the value would always be exposed in the logs.

1 Eternity Later#

After a reasonable amount of trial and error, and so much searching, I came up with a fairly straightforward solution that allowed me to have a dynamic input without exposing the value until after it was masked. The solution?

A simple one-time link that would provide the secret value workflow.

To initially test my theory, I added a simple handler to my personal API that would behave like most secret sharing services, but without me having to bother picking (or paying for) one until I knew this could work:

  • POST https://api.syntaqx.com/secrets - Create a new secret, get back a one-time link
  • GET https://api.syntaqx.com/secrets/{id} - Retrieve the secret, which deletes it

Here's the round trip with curl:

$ curl -s https://api.syntaqx.com/secrets \
    -H 'Content-Type: application/json' \
    -d '{"secret": "SHHH IM SECRET", "ttl": 600}'
{"id":"pX3c0Qb7e1u9KkHn4WfTz2aLr8YdVs6M","url":"https://api.syntaqx.com/secrets/pX3c0Qb7e1u9KkHn4WfTz2aLr8YdVs6M","expires_at":"2026-10-29T15:33:45.000Z"}

$ curl -s https://api.syntaqx.com/secrets/pX3c0Qb7e1u9KkHn4WfTz2aLr8YdVs6M
{"secret":"SHHH IM SECRET"}

$ curl -s https://api.syntaqx.com/secrets/pX3c0Qb7e1u9KkHn4WfTz2aLr8YdVs6M
{"message":"No secret with that id. It was already read, expired, or never existed."}

Creating a secret

To make things easy, I created a simple create-secret.sh script that would give me back a one-time link to retrieve the secret value. It prompts for the value rather than taking it as an argument, so it stays out of your shell history:

#!/usr/bin/env bash
# Usage: ./create-secret.sh [ttl-seconds]
set -euo pipefail

read -rsp 'Secret: ' secret
echo >&2

printf '%s' "$secret" \
  | jq -Rs --argjson ttl "${1:-3600}" '{secret: ., ttl: $ttl}' \
  | curl -sSf https://api.syntaqx.com/secrets \
      -H 'Content-Type: application/json' \
      --data-binary @- \
  | jq -r '.url'
$ ./create-secret.sh 600
Secret:
https://api.syntaqx.com/secrets/pX3c0Qb7e1u9KkHn4WfTz2aLr8YdVs6M

Not a terminal person? The One-Time Secrets page makes the same link: paste the value, tick For scripts, and copy what it gives you.

Then, I could simply run the workflow, and provide the key and url as inputs:

name: Upsert Secret Environment Variable

on:
  workflow_dispatch:
    inputs:
      key:
        description: 'Key'
        required: true
        type: string
      url:
        description: 'One-time Link (https://api.syntaqx.com/secrets/{id})'
        required: true
        type: string

jobs:

  upsert:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      -
        name: Retrieve the secret value
        id: secret
        env:
          SECRET_URL: ${{ inputs.url }}
        run: |
          set -o pipefail
          if [[ "$SECRET_URL" != https://api.syntaqx.com/secrets/* ]]; then
            echo "::error::url must be a https://api.syntaqx.com/secrets/ link"
            exit 1
          fi
          VALUE=$(curl -sSf "$SECRET_URL" | jq -r '.secret') || {
            echo "::error::That link was already used, has expired, or never existed"
            exit 1
          }
          echo "::add-mask::$VALUE"
          echo "value=$VALUE" >> "$GITHUB_OUTPUT"
      -
        env:
          KEY: ${{ inputs.key }}
          VALUE: ${{ steps.secret.outputs.value }}
        run: |
          echo "$KEY=$VALUE"

The inputs go through env rather than straight into the script with ${{ }}, so a crafted input can't inject commands into the step. The link itself does show up in the log, but by the time anyone can read it, the workflow has already used it.

Keeping the server out of it

A For scripts link means the server encrypts the secret, so for a moment it sees the value. A regular link from One-Time Secrets doesn't have that problem: the browser encrypted it, and the key is in the link after the #.

You can make the same kind of link from a terminal, so the value is encrypted before it leaves your machine. This version of create-secret.sh needs Node 20 or later:

#!/usr/bin/env bash
# Usage: ./create-secret.sh [ttl-seconds]
set -euo pipefail

read -rsp 'Secret: ' secret
echo >&2

printf '%s' "$secret" | node --input-type=module -e '
  const chunks = [];
  for await (const chunk of process.stdin) chunks.push(chunk);
  const raw = crypto.getRandomValues(new Uint8Array(32));
  const key = Buffer.from(raw).toString("base64url");
  const aes = await crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt"]);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aes, Buffer.concat(chunks));
  const check = await crypto.subtle.digest("SHA-256", Buffer.from(`check:${key}`));
  const res = await fetch("https://api.syntaqx.com/secrets", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      secret: Buffer.concat([iv, Buffer.from(sealed)]).toString("base64url"),
      ttl: Number(process.argv[1]),
      check: Buffer.from(check).toString("base64url"),
    }),
  });
  if (!res.ok) throw new Error((await res.json()).message);
  const { id } = await res.json();
  console.log(`https://www.syntaqx.com/tools/secrets#${id}/${key}`);
' "${1:-3600}"
$ ./create-secret.sh 600
Secret:
https://www.syntaqx.com/tools/secrets#pX3c0Qb7e1u9KkHn4WfTz2aLr8YdVs6M/Qm9yZS1rZXktZXhhbXBsZS1ub3QtYS1yZWFsLWtleQA

That link opens on the page for a person, or in a workflow. The runners already have Node, so the workflow can decrypt it the same way the page does. Swap the retrieve step for this one, and pass the page link as url:

      -
        name: Retrieve the secret value
        id: secret
        env:
          SECRET_URL: ${{ inputs.url }}
        run: |
          if [[ "$SECRET_URL" != https://www.syntaqx.com/tools/secrets#* ]]; then
            echo "::error::url must be a https://www.syntaqx.com/tools/secrets# link"
            exit 1
          fi
          VALUE=$(node --input-type=module -e '
            const [id, key] = new URL(process.argv[1]).hash.slice(1).split("/");
            const check = await crypto.subtle.digest("SHA-256", Buffer.from(`check:${key}`));
            const res = await fetch(`https://api.syntaqx.com/secrets/${id}`, {
              headers: { "Secret-Check": Buffer.from(check).toString("base64url") },
            });
            if (!res.ok) throw new Error("That link was already used, has expired, or never existed");
            const bytes = Buffer.from((await res.json()).secret, "base64url");
            const aes = await crypto.subtle.importKey("raw", Buffer.from(key, "base64url"), "AES-GCM", false, ["decrypt"]);
            const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.subarray(0, 12) }, aes, bytes.subarray(12));
            process.stdout.write(Buffer.from(plain));
          ' "$SECRET_URL")
          echo "::add-mask::$VALUE"
          echo "value=$VALUE" >> "$GITHUB_OUTPUT"

The format is small on purpose: the key is 32 random bytes, and the stored value is the 12-byte IV followed by the AES-256-GCM ciphertext, both base64url. The Secret-Check header is SHA-256 of check: plus the key. The secret was created with the same value, and the API won't give the ciphertext up without it, so someone holding the link without its key can't burn it. Leave Send the key separately off for links you hand to a workflow, since the step expects the key in the link.

Which surprisingly worked! The value was masked and never exposed in the logs.

SECRET_KEY=***

Run it a second time with the same link and the retrieve step fails with "That link was already used", which is exactly what you want from a one-time link. We have a path forward.

Conclusion#

While this isn't perfect (anyone could easily just modify the workflow to echo $RESPONSE before the value is masked), assuming proper pull requests and code reviews, this should be secure enough for my use case.

Honestly, I'm pretty happy with the solution, but I'm not happy that I had to come up with it. I would love to see GitHub support dynamic secrets in workflows so that I don't have to jump through hoops to keep my secrets secret.

Until then, I'll keep using this, and hope that it helps someone else.

Open Letter to GitHub#

Please, support dynamic secrets in workflows.

  workflow_dispatch:
    inputs:
      key:
        description: 'Key'
        required: true
        type: string
      value:
        description: 'Value'
        required: true
        type: secret

I know it's not just me asking.