Skip to content

Key Scope

A dotsecenv CI keypair is the identity a GitHub Actions job logs in as to decrypt the committed vault. You can give every repository its own keypair, or share one keypair across many repositories. The choice is a tradeoff between blast radius and bootstrap cost.

A repo-scoped keypair is one CI/decryption keypair per repository. Each repo carries its own private key in its own secret, shared to the secrets in that repo’s vault and nothing else.

An org-wide keypair is one keypair shared across many repositories. A single private key decrypts the vaults of every repo that lists it as a recipient. You bootstrap the key once and reuse it everywhere.

The vault mechanics are identical either way. Scope is only a question of how many vaults a single private key can open.

Repo-scopedOrg-wide
A leaked key exposesSecrets in one repo’s vaultSecrets in every repo that shared to the key
Rotation costRe-key one repoRe-key every repo that used it
Bootstrap costOnce per repoOnce, then reuse
ConvenienceLower (repeat the setup)Higher (set up once)
Least privilegeStrong (key opens one vault)Weak (key opens many)

A leaked repo-scoped key is contained to a single repository. A leaked org-wide key is a fleet-wide incident. See Threat Model for what a compromised private key lets an attacker do, and for the keychain and AI-agent vectors that make leaks plausible.

The secret name signals which scope you intend.

ScopeSecret namePassphrase variant
Repo-scoped (default)GPG_PRIVATE_KEYGPG_PASSPHRASE
Org-wideORG_GPG_PRIVATE_KEYORG_GPG_PASSPHRASE

Per-environment keys suffix the name: GPG_PRIVATE_KEY_DEV, GPG_PRIVATE_KEY_STAGING, GPG_PRIVATE_KEY_PROD, and likewise ORG_GPG_PRIVATE_KEY_PROD for an org-wide production key. The unsuffixed name is the single-environment default.

Private keys are stored ASCII-armored, exactly as gpg --armor --export-secret-keys <FP> emits them. Import in the workflow with echo "$KEY" | gpg --batch --import. Do not base64-encode the key.

If the CI key carries a passphrase, map the passphrase secret to the env var the binary reads:

env:
DOTSECENV_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}

The current examples use --no-passphrase CI keys, so the passphrase variant is optional. Always log in by fingerprint: dotsecenv login <FINGERPRINT>.

The secret name documents intent. The actual scope comes from where the secret is defined in GitHub.

A repository secret is visible only to workflows in that one repository. A repo-scoped key belongs here.

An organization secret is defined once at the org level with a repository-access policy (all repos, private repos, or a selected list). Every repo the policy covers can read it. An org-wide key belongs here.

So a key named ORG_GPG_PRIVATE_KEY stored as a repository secret is reachable by exactly one repo; the name lies about its reach. A key named GPG_PRIVATE_KEY stored as an org secret with broad access is reachable by the whole fleet, whatever the name suggests. Name the secret to match where you put it. GitHub decides the reach.

Prefer the narrowest scope that works. Repo-scoped keys are the least-privilege default: one key opens one vault, a leak is contained, and rotation touches one repo.

Reach for an org-wide key only when re-bootstrapping every repository is the cost you are trying to avoid: many repos, one platform team, the same CI identity everywhere. You are trading a larger blast radius for not repeating the setup. Per-environment suffixes keep that trade narrower for production. An org-wide ORG_GPG_PRIVATE_KEY_PROD can be a separate key from the dev/staging one, so a leaked non-prod key never touches production vaults.

The bare name GPG_PRIVATE_KEY is also what crazy-max/ghaction-import-gpg and GoReleaser use for release signing. A repository that both signs releases and runs dotsecenv in CI will have two unrelated keys fighting for the same secret name. Rename one. The dotsecenv side can move to a suffixed or ORG_-prefixed name; the release-signing side has its own configuration knob.

Rotation cost follows scope.

Rotating a repo-scoped key is contained to one repository: generate a new key, secret share the repo’s secrets to its fingerprint, commit the updated vault, and replace the repository secret. Nothing outside that repo changes.

Rotating an org-wide key is a fan-out. Every repository that shared secrets to the old key has to re-grant: run secret share to the new fingerprint and commit the updated vault in each repo. Then re-import the new private key wherever it lives: the org secret, plus any per-environment variants. Until every repo is re-shared and the org secret is replaced, you are running with a key you meant to retire. This fan-out is the standing cost of the convenience an org-wide key buys.