Managing GitHub Actions Secrets Across Many Repositories

Most of my deploy pipelines run on GitHub Actions, and most of them need the same kinds of secrets: SSH host and key, a VPN config, cloud credentials, API tokens. With one repository per service and several environments per repository, that quickly becomes dozens of secrets per project, and setting them up means a lot of clicking.

GitHub's settings page handles one secret at a time. You can't paste a .env file, and once a secret is saved you can't see its value again. That second part is a good security decision. It also means that "copy the secrets from the old repo to the new one" is not something the UI can help you with.

So I built GitHub Secrets Editor, a small Next.js app. You log in with a personal access token, pick a personal or organization repository, and edit its secrets as a list: add, rename, delete, or paste a whole .env file at once.

How the secrets API works

The REST API for Actions secrets is small. Four endpoints cover everything:

GET  /repos/{owner}/{repo}/actions/secrets              # names only, never values
GET  /repos/{owner}/{repo}/actions/secrets/public-key   # { key_id, key }
PUT  /repos/{owner}/{repo}/actions/secrets/{name}       # { encrypted_value, key_id }
DELETE /repos/{owner}/{repo}/actions/secrets/{name}

The interesting one is PUT. GitHub doesn't accept the secret in plain text, even over HTTPS. You first fetch the repository's public key, encrypt the value on your side with a libsodium sealed box, and send the ciphertext together with the key ID. Only GitHub's runners can decrypt it.

The encryption is a few lines:

import { seal } from "tweetsodium";

export const encryptSecret = (secret: string, publicKey: string): string => {
  const messageBytes = Buffer.from(secret);
  const keyBytes = Buffer.from(publicKey, "base64");
  const encryptedBytes = seal(messageBytes, keyBytes);
  return Buffer.from(encryptedBytes).toString("base64");
};

A sealed box uses a throwaway key pair on every call, so the same value encrypts to a different ciphertext each time. That's expected. If you find yourself comparing ciphertexts to check whether a value changed, it won't work.

Editing something you can't read

The list endpoint returns names only. So the editor shows every existing secret with an empty value field, and the save logic treats an empty value as "leave this one alone":

const updateSecrets = async (repo: string, secrets: Secret[]) => {
  const { data } = await githubApi.get("/repos/" + repo + "/actions/secrets");
  const existing = new Set(data.secrets.map((s) => s.name));
  const wanted = new Set(secrets.map((s) => s.name));

  // removed in the editor -> removed on GitHub
  for (const name of existing) {
    if (!wanted.has(name)) {
      await githubApi.delete("/repos/" + repo + "/actions/secrets/" + name);
    }
  }

  const { data: key } = await githubApi.get("/repos/" + repo + "/actions/secrets/public-key");

  for (const secret of secrets) {
    // empty value = "keep what GitHub already has"
    if (!secret.name.trim() || !secret.value.trim()) continue;

    await githubApi.put("/repos/" + repo + "/actions/secrets/" + secret.name, {
      encrypted_value: encryptSecret(secret.value, key.key),
      key_id: key.key_id,
    });
  }
};

That gives the three operations you actually need with only one rule to remember:

Importing a .env file

The feature I use most is pasting a .env file. The parser is deliberately simple: skip blank lines and comments, split on the first =, and strip surrounding quotes.

const handleEnvPaste = (content: string) => {
  const secrets: Secret[] = [];

  for (const line of content.split("\n")) {
    if (!line.trim() || line.trim().startsWith("#")) continue;

    const match = line.match(/^([^=]+)=(?:"([^"]*)"|(.*))$/);
    if (!match) continue;

    const [, name, quoted, plain] = match;
    const value = quoted || plain;
    if (name && value) secrets.push({ name: name.trim(), value: value.trim() });
  }

  onBulkAdd(secrets);
};

It doesn't try to handle multi-line values or escape sequences. For SSH private keys and VPN configs, which are multi-line, you add the secret as a single entry and paste the value into the textarea instead.

Where the token lives

The app talks to the GitHub API directly from the browser. There is no backend of its own, and nothing is stored: the token stays in memory for the session, and secret values only exist in the form until you save. That was a deliberate choice: a token with repo scope should not pass through anyone else's server.

If you use it, a fine-grained token limited to the repositories you are editing, with read and write access to secrets only, is better than a classic token with full repo scope.

What I would change today

Even in its current form, it turns a long round of clicking through settings pages into pasting a file and pressing save.

← All posts