Share .env with your team, not the internetage, git, and the alternatives that actually work.
Every team needs to share secrets. Most end up on a SaaS server they do not control. There is a simpler path that keeps the keys on your laptops and the encrypted blob in the repo.
-
01
Option A
Upload to SaaS vault
Plaintext secrets cross a network to a server you don't operate
trust: their infra -
02
Option B
age-encrypted in git
Encrypted blob in repo, decrypted locally, no external account
trust: your laptops -
03
Tradeoff
Trust boundary
SaaS is convenient; encrypted git is offline-safe and auditable
your call
TL;DR· the answer, in twenty seconds
What: Small teams routinely upload secrets to a SaaS vault to share them, which moves the trust boundary from your laptops to a server you do not run.
Fix: Encrypt .env with age using each teammate's public key, commit the .age file, and decrypt locally with age -d secrets/.env.age > .env. Public keys live in the repo; private keys stay on each laptop.
Lesson: Sharing encrypted blobs instead of uploading plaintext keeps the trust boundary inside your team, and the approach works for any file format.
Slack DM works until someone leaves. A shared .env in Dropbox works until Dropbox leaks. A SaaS secrets manager works until you need to debug an incident without internet access, or until the vendor raises prices, or until you realize you have handed plaintext environment variables to a third party you have never read a security whitepaper about.
For a team of two to five people, the cryptography to do this without a third party has been sitting in a 50-line binary for years. age (actually good encryption) was published by Filippo Valsorda and has been stable since 2021. The pattern: each teammate generates a keypair, public keys go in the repo, private keys stay on each laptop. You encrypt the .env file against all public keys, commit the .age file, and each teammate decrypts locally.
Nothing leaves your machines in plaintext. The repo holds a blob only your team can open. When someone leaves, you remove their key, re-encrypt, and commit.
What to know in 60 seconds
ageis a small, audited encryption tool.brew install ageon macOS,apt install ageon most Linux. No GPG.- Each teammate runs
age-keygenonce. The private key stays on their laptop. The public key goes in the repo. - You encrypt with
age -R .age-recipients -o secrets/.env.age .envand decrypt withage -d -i ~/.age/key.txt secrets/.env.age > .env. - The decrypted
.envbelongs in.gitignore. The.agefile is safe to commit. - On a teammate's last day, regenerate the encrypted file without their public key and rotate any secrets they had access to.
The age + git pattern in 10 minutes
Full setup for a three-person team, about ten minutes.
Step 1: Everyone generates a key
Each teammate runs this once:
age-keygen -o ~/.age/key.txt
The output file contains two lines: a comment with the public key (# public key: age1...) and the private key. Share the public key only. The private key never leaves the laptop.
To extract the public key for sharing:
grep "public key" ~/.age/key.txt | awk '{print $NF}'
Step 2: Collect public keys in the repo
Create a file called .age-recipients at the repo root. One public key per line:
# alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# bob
age1lggyhqrw2nlhcxprm67z43rta597askmsvl2a5hr4m54t4c0n0uq6yqffa
# carol
age1p4l0tq2lfhq7p9h0kxy8j0k8n6tqv7mj3n0a5f9pq7z4r8w5k2q0x3c9s
Commit .age-recipients to the repo. It is not sensitive.
Step 3: Create a secrets directory
mkdir -p secrets
echo "secrets/*.env" >> .gitignore
The secrets/ directory holds the .age files you commit. The decrypted .env files go in .gitignore.
Step 4: Encrypt the .env file
age -R .age-recipients -o secrets/.env.age .env
git add secrets/.env.age .age-recipients
git commit -m "chore: add encrypted secrets"
Any teammate can now decrypt it:
age -d -i ~/.age/key.txt secrets/.env.age > .env
Step 5: Update when secrets change
Whenever you rotate a credential or add a new value, re-encrypt:
age -R .age-recipients -o secrets/.env.age .env
git add secrets/.env.age
git commit -m "chore: rotate stripe key"
That is the whole pattern. No account, no API, no monthly bill. Secret change history is in your git log.
Alternatives worth knowing
The age + git pattern covers most small-team cases, but a few situations call for something different.
SOPS + age
If your secrets live in YAML or JSON rather than a dotenv file, SOPS (from Mozilla) handles structured file encryption on top of age (or GPG or AWS KMS). The .sops.yaml config file specifies which keys to use and which fields to encrypt, so values get ciphertext while keys stay readable. Good for Kubernetes manifests or config files with mixed sensitive and non-sensitive fields.
brew install sops
# .sops.yaml in repo root
cat > .sops.yaml << 'EOF'
creation_rules:
- path_regex: secrets/.*\.yaml$
age: age1ql3z7hjy54pw3hyww5...,age1lggyhqrw2nlhcxprm6...
EOF
sops --encrypt secrets/config.yaml > secrets/config.enc.yaml
sops --decrypt secrets/config.enc.yaml
1Password shared vault
If your team already uses 1Password Business or Teams, a shared vault is SaaS but end-to-end encrypted. 1Password holds the decryption keys client-side, so 1Password the company does not see your values in plaintext. The op CLI lets you inject secrets at runtime: op run --env-file=.env.1p -- npm start. Good for human-shared secrets where you want GUI access or mobile access. Still counts as a SaaS dependency.
Encrypted DM + passphrase
For a one-off transfer, encrypt the .env with a passphrase and send the ciphertext via one channel, the passphrase via another (Signal for both is fine):
age -p -o .env.age .env # prompts for passphrase
age -d .env.age > .env # decrypts with the same passphrase
Delete the passphrase message after the recipient confirms they have it. This does not scale to ongoing rotation, but it covers the "new contractor needs the staging key once" situation.
Where it breaks
Four failure modes come up repeatedly. Know them before you hit them.
Sharing the private key. If a teammate cannot decrypt, the first instinct is to share the private key. Do not. The point of asymmetric encryption is that each person decrypts with their own key. If someone loses their private key, remove their public key from .age-recipients, regenerate the encrypted file with the remaining keys, and let them generate a new keypair.
Decrypting to the working tree when an AI agent is running. If you run age -d secrets/.env.age > .env and then open your project in Claude Code or Cursor, the agent reads the decrypted file. The .age file and the pattern are not to blame. The decrypted .env living next to your code is the problem.
For agent sessions, prefer the inline form:
age -d -i ~/.age/key.txt secrets/.env.age | env $(xargs) -- npm run dev
That pipes decrypted values into the child process environment without writing a file. The values exist inside that one process's address space and vanish when it exits. The agent never reads a file named .env.
Forgetting to update recipients when someone leaves. Old public keys decrypt old encrypted blobs. If you remove a departing teammate's key from .age-recipients but do not re-encrypt, they can still decrypt any version of the file from git history. The correct sequence: remove the key, re-encrypt, commit, then rotate any secrets the departed person had access to. All three steps, in order.
Encrypting with the wrong recipient list. If you add a new teammate but forget to add their public key to .age-recipients before re-encrypting, they cannot decrypt. Check the recipient list matches the team before each encrypt operation. A short script helps:
#!/bin/sh
# encrypt-secrets.sh
set -e
team=$(wc -l < .age-recipients | tr -d ' ')
echo "Encrypting for ${team} recipients..."
age -R .age-recipients -o secrets/.env.age .env
echo "Done. Verify the commit includes both files."
The case against SaaS secrets managers for small teams
The conventional advice is to use Doppler, Infisical, or AWS Secrets Manager from day one, on the basis that those tools handle audit logs, access control, and rotation.
That advice is correct for teams that need compliance evidence, role-based access, or integration with cloud IAM. For a three-person startup working on staging secrets before product-market fit, it is overkill that introduces a new trust boundary.
Every SaaS secrets manager requires you to trust that the vendor stores and transmits your plaintext secrets correctly. The vendors are generally trustworthy. The attack surface is not zero. Every SaaS service with a support tier has support engineers with elevated access; compromised support sessions have exposed customer secrets at vendors across the industry. The category of risk is the same regardless of which product you choose.
With age + git, the trust boundary is your team's laptops and your git host. You already trust those. The encrypted blob on GitHub or GitLab is worthless without a private key that never leaves your machine.
SaaS becomes the right call when you need audit logs that satisfy a compliance framework, or when you have more than 10 people and the recipient list becomes a maintenance burden. Below that threshold, the complexity cost of a SaaS dependency is hard to justify.
Checklist you can paste into a PR
## Team secrets setup audit
- [ ] Every team member has generated an age keypair (age-keygen -o ~/.age/key.txt)
- [ ] All current team member public keys are in .age-recipients
- [ ] .age-recipients is committed to the repo
- [ ] secrets/ directory exists and is in git
- [ ] Decrypted .env files are in .gitignore
- [ ] age-encrypted .age files are committed and up to date
- [ ] Tested: each active team member can decrypt successfully
- [ ] Departed team members' public keys removed, file re-encrypted after any offboarding
- [ ] Secrets rotated after any offboarding (not just re-encrypted)
- [ ] CI/CD uses a dedicated age key or a secrets injection step, not a personal key
- [ ] AI agent sessions use inline decryption (age -d | env $(xargs) -- command) not .env files
Paste into docs/secrets.md or a PR template. Review it when someone joins or leaves the team.
What this means for your stack
The age + git pattern solves distribution. It does not solve what happens when a secret leaves the file and enters a process. Decrypt to disk and open the project in a coding agent: the value is in the agent's context. A CI runner that logs environment variables in debug mode puts the value in a log line that persists for 90 days.
The durable fix is to keep secrets out of files the agent reads and out of logs entirely. A local secret broker injects values at execution time into a specific child process, writes an audit record, and lets them vanish when the process exits. The agent reads a reference, not the value itself.
hasp is one working implementation. curl -fsSL https://gethasp.com/install.sh | sh, hasp setup, connect a project, and hand the next agent session a reference instead of a key. Source-available (FCL-1.0), local-first, macOS and Linux, no account.
The lesson holds regardless of which tool you use. Distribution is the easy part. The harder problem is keeping the decrypted value inside a narrow process boundary and out of every file, log, and context window that surrounds it.
Sources· cited above, in one place
Stop handing the agent your real keys.
hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.
- Local, encrypted vault — no account, no cloud, no telemetry by default.
- Brokered run — agent gets a reference, the child process gets the value.
- Pre-commit + pre-push hooks catch managed values before they ship.
- Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.