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:
- Delete: remove the row and the secret is deleted on save.
- Change: type a new value and it is encrypted and overwritten.
- Keep: leave the value empty and nothing is sent for that secret.
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
tweetsodiumis deprecated. GitHub's documentation now useslibsodium-wrappers, and switching is a few lines.- Repository lists aren't paginated yet, so an organization with more than one page of repositories won't show all of them.
- The bigger missing feature is the one in the title: applying the same set of secrets to several repositories in one go, plus environment-level and organization-level secrets.
Even in its current form, it turns a long round of clicking through settings pages into pasting a file and pressing save.