GUIDE · HOW-TO 8 min ·

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.

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

  • age is a small, audited encryption tool. brew install age on macOS, apt install age on most Linux. No GPG.
  • Each teammate runs age-keygen once. 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 .env and decrypt with age -d -i ~/.age/key.txt secrets/.env.age > .env.
  • The decrypted .env belongs in .gitignore. The .age file 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

NEXT STEP~90 seconds

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.
→ okvault unlocked · binding ./api
→ okgrant once · pid 88421
→ okagent never read

macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.

Browse all clusters· eight threads, one index