Sharing secrets with a teammate, the GPG way
A new teammate needs the staging database password and a Stripe test key. The fastest way to hand them over is to paste them into a chat message. That message is then stored, synced to every device both of you are signed in on, searchable, and kept for as long as your workspace keeps history, which is usually much longer than the onboarding.
The alternative people reach for next is a password manager with shared vaults, and for a team that shares secrets every week it's the right answer. But sometimes it's just two people, once, with no shared account. For that case envsec has envsec share, which encrypts a context with GPG for one recipient. This post explains exactly what it does, how the receiver imports the result, and where GPG falls short.
What envsec share does
envsec share takes one context and one recipient. It reads every secret of the context from the OS credential store, builds a plaintext payload in memory, and pipes it into gpg on standard input. This is the call, from packages/cli/src/cli/share.ts:
execFileSync("gpg", [
"--batch", "--yes",
"--trust-model", "always",
"--encrypt", "--armor",
"--recipient", recipient,
], { encoding: "utf-8", input: plaintext });A few details follow from that call:
- No plaintext on the sender's disk, nothing in argv. Values travel through a pipe to
gpg, so they never appear in a temporary file or in the process list. - The output is ASCII-armored (
--armor): a-----BEGIN PGP MESSAGE-----block you can attach to an email or drop into a file. Without-oit goes to stdout; with-o fileenvsec writes the file and prints a summary on stderr. - One recipient per run.
--encrypt-tois required and accepts an email, a key ID or a fingerprint, which gpg resolves against your keyring. - Missing values are skipped, with a warning. If the metadata lists a key whose keychain entry is gone, envsec prints
▲ Skipped 1 secret no longer in keychainfollowed by the key names, and shares the rest.
The payload inside is the same format envsec env-file writes: one KEY="value" line per secret, the key upper-cased with dots turned into underscores, and backslashes, double quotes and newlines escaped:
DB_PASSWORD="p@ss\"w0rd"
STRIPE_SECRET_KEY="sk_test_123"
TLS_CERT="-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"With the global --json flag the payload is a JSON object instead, { context, secrets: [{ key, value }] }, with the original key names.
Why GPG
I didn't want envsec to implement any cryptography of its own, and I wanted the receiver to be able to decrypt the file without envsec installed. GPG fits both:
- It's everywhere. Linux distributions package it, Homebrew has it, and Gpg4win covers Windows. envsec only needs a
gpgbinary on thePATH. - People already have keys. Some of your teammates may already use one, for example to sign git commits. If they do, there is no setup at all.
- The output is a standard format. Anyone with the private key can open it with
gpg --decrypt, years from now, with or without envsec.
A complete example
Alice has the myapp.dev secrets; Bob needs them. Both have gpg and envsec installed. Bob starts, because Alice needs his public key:
$# Bob: create a key pair (skip this if you already have one)$gpg --quick-gen-key "Bob Example <bob@example.com>" default default 1y$# Export the public key and send it to Alice; any channel is fine$gpg --armor --export bob@example.com > bob.pub.asc$# Show the fingerprint, to read out to Alice on a call$gpg --fingerprint bob@example.com--quick-gen-key asks for a passphrase to protect the private key. The fingerprint check is the step people skip, and it is the one that matters: it's how Alice knows the key she received is Bob's and not someone else's. Alice imports the key, compares fingerprints, and encrypts:
$# Alice: import Bob's key and compare the fingerprint with what he read out$gpg --import bob.pub.asc$gpg --fingerprint bob@example.com$# Encrypt the whole context to that exact key$envsec -c myapp.dev share --encrypt-to 1DB04155CDCADCE4B08ED3035648FD56E16C4BB8 -o myapp-dev.asc◈ Encrypted 3 secrets from "myapp.dev" for 1DB04155CDCADCE4B08ED3035648FD56E16C4BB8 → myapp-dev.ascShe sends myapp-dev.asc however she likes; it's encrypted. Bob imports it with envsec load. There is no dedicated import command: load reads the .env format that share produces. It takes a file path (-i, default .env) rather than standard input, so on macOS and Linux the pipe goes through /dev/stdin and the decrypted text never lands on disk:
$# Bob: decrypt straight into envsec, no plaintext file$gpg --quiet --decrypt myapp-dev.asc | envsec -c myapp.dev load -i /dev/stdin$# Done with the file$rm myapp-dev.asc✔ Done: 3 added, 0 overwritten, 0 skippedProcess substitution works as well: -i <(gpg -q -d …) in bash and zsh, -i (gpg -q -d … | psub --fifo) in fish. Note the --fifo: plain psub writes the output to a temporary file. On Windows there is no /dev/stdin, so you would decrypt to a file and delete it after the import. If myapp.dev already has one of the keys, load skips it and tells you; add --force to overwrite.
What doesn't survive the trip
share and load are two separate commands joined by the .env format, and that format is lossy for envsec keys:
- Underscores and case.
loadmapsSTRIPE_SECRET_KEYback by lower-casing it and turning every underscore into a dot. A key namedstripe.secret_keyon Alice's side arrives asstripe.secret.keyon Bob's. Keys made only of lower-case segments without underscores, likedb.password, round-trip unchanged. - Metadata. Expiry dates set with
add --expiresaren't in the payload, soenvsec auditon Bob's machine won't know about them. - Selection.
sharealways sends the whole context. To send a subset, copy it into a temporary context first (envsec -c myapp.dev copy "stripe.*" --to tmp.bob), share that, then delete it.
The --json payload keeps the exact key names, but there is no command that imports it today.
The downsides of GPG
The user experience
Keyrings, trust models, subkeys, the agent, pinentry, expiry dates: GPG asks a lot of people who only want to send a password. The example above is the short path, and it still takes six commands across two people before anything is encrypted.
Key management
If Bob loses his private key or forgets the passphrase, the file is unreadable and Alice has to share again. If his key expires or is revoked, Alice needs the updated key before the next share. None of this is hard, but all of it is manual.
No forward secrecy
The file is encrypted to Bob's long-term key. Anyone who keeps a copy of it, in a mailbox, a chat upload or a backup, and later gets Bob's private key and passphrase can decrypt it. That's why the example ends with rm, and why the file should travel through something you can delete it from.
Metadata
The content is encrypted; the envelope is not. The message carries the ID of the key it was encrypted to (gpg --list-packets shows it), its size hints at how much is inside, and the channel you send it through records who sent what to whom, and when.
No proof of who sent it
envsec encrypts but doesn't sign. Anyone with Bob's public key can produce a file that looks the same, and Bob can't tell from the file alone that Alice made it. If that matters, Alice can sign the encrypted file separately:
$# Alice: sign the encrypted file with her own key$gpg --armor --detach-sign myapp-dev.asc$# Bob: check the signature before importing (needs Alice's public key)$gpg --verify myapp-dev.asc.asc myapp-dev.ascTrusting the wrong key
envsec passes --trust-model always, so gpg encrypts to whatever key matches the recipient without checking whether anyone certified it. Without that flag, gpg in batch mode refuses a freshly imported key that nobody you trust has certified, with "There is no assurance this key belongs to the named user". That would stop most first-time users. The cost is that the fingerprint check is entirely up to you. Pass the full fingerprint, not an email: when I tried an email that wasn't in my keyring, gpg went looking for a key over the network.
The alternatives
- age. A small, modern file encryption tool with short
age1…public keys, multiple recipients with repeated-r, and the option to encrypt to someone's SSH public key. It is much easier to get right than GPG. It doesn't sign either, andenvsec sharedoesn't support it today. - sops. Encrypts the values inside YAML, JSON, ENV and INI files while the keys stay readable, with age, PGP or cloud KMS keys. That makes it a good fit for secrets that live in a repository and are decrypted by several people and by CI. It's more setup than a one-off handover needs.
- 1Password shared vaults. For ongoing sharing this is better than any file: access is revoked centrally, and a changed value reaches everyone. It needs a paid account for everyone involved. envsec vs dotenv vs 1Password CLI vs direnv covers when that trade is worth it.
- Slack, Teams, email. Don't. And if a secret has already been pasted into one, rotate it.
In short
- The receiver sends a public key and reads out its fingerprint on a separate channel.
- The sender runs
envsec -c ctx share --encrypt-to FINGERPRINT -o file.asc. - The receiver runs
gpg -q -d file.asc | envsec -c ctx load -i /dev/stdinand deletes the file.
It's a handover, not a sync: nothing updates when the value changes, and nothing tells you who still has a copy. For two people and one context, that's often all you need. For anything recurring, use a shared vault. The docs list every share and load flag, and What envsec does not protect you from covers the rest of the threat model.