TypeScript

Set a GitHub Actions secret the right way

You cannot POST a plaintext Actions secret. You fetch the repo public key, seal the value with libsodium, then PUT the ciphertext.

23 Jun 2026

Three steps: get the key, seal, put. The seal is the part people skip and then wonder why the API rejects them.

import sodium from "libsodium-wrappers";
 
interface PublicKey {
  key: string; // base64
  key_id: string;
}
 
export async function setSecret(opts: {
  owner: string;
  repo: string;
  name: string;
  value: string;
  token: string;
}): Promise<void> {
  const api = `https://api.github.com/repos/${opts.owner}/${opts.repo}/actions/secrets`;
  const headers = {
    Authorization: `Bearer ${opts.token}`,
    Accept: "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
  };
 
  const keyRes = await fetch(`${api}/public-key`, { headers });
  if (!keyRes.ok) throw new Error(`public-key: ${keyRes.status}`);
  const pk = (await keyRes.json()) as PublicKey;
 
  await sodium.ready;
  const sealed = sodium.crypto_box_seal(
    sodium.from_string(opts.value),
    sodium.from_base64(pk.key, sodium.base64_variants.ORIGINAL),
  );
  const encrypted_value = sodium.to_base64(
    sealed,
    sodium.base64_variants.ORIGINAL,
  );
 
  const putRes = await fetch(`${api}/${opts.name}`, {
    method: "PUT",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ encrypted_value, key_id: pk.key_id }),
  });
  if (!putRes.ok && putRes.status !== 201 && putRes.status !== 204) {
    throw new Error(`put secret: ${putRes.status}`);
  }
}

Gotchas

You cannot just send the plaintext. GitHub stores secrets sealed against a repo-specific public key, so the value has to be encrypted with crypto_box_seal and that exact key before the PUT. Send key_id from the same response, or the server cannot pick the matching private key to decrypt.

Related
now runningwhisper_scheduleopen