Security
envless inherits its cryptographic guarantees from age and sops.
It is a thin orchestrator; it adds no new primitives, but it also adds
no new attack surface beyond the shell-out to those binaries. This
page walks assumptions → threat model → crypto → rotation →
operational hardening → integration patterns → supply chain, in that
order.
Assumptions
Read these before the rest. envless is correct only under these
preconditions. If any are false for your environment, you need a
different tool (most likely a cloud KMS).
- Developer machines are reasonably trusted. Not compromised by malware, not multi-tenant, no untrusted users on the same login.
- Recipients are not actively hostile. Adding someone to
.envless/recipientsis a one-way trust grant. There is no time- scoped credential, no “read-only” tier, no per-key ACL. - Git history is not adversary-accessible at rest. The encrypted
files are safe in public repos, but you should not assume an
attacker who clones your repo has less power than one who reads
.envless/identity.keydirectly. sopsandagebinaries onPATHare uncompromised.envlessdoes not verify their signatures (yet — see roadmap).- You have an out-of-band way to enrol new recipients. Adding a
pubkey to
recipientsitself goes through your normal code-review channel (a PR).envlessdoes not bootstrap trust. - No regulatory regime requires hardware-backed key custody. PCI, HIPAA, SOC 2 with mandated HSM, FedRAMP — none of these are in-scope. See When NOT to use below.
If even one of these is false for your case, stop here and use AWS KMS / GCP KMS / HashiCorp Vault.
Threat model
Adversary tiers
envless is opinionated about which adversary it covers. Be
explicit with yourself about which row you’re really worried about.
| Tier | Examples | Covered by envless? |
|---|---|---|
| Casual | shoulder-surfer, console.log(process.env) in shared chat, accidental git push of .env | Yes — by design. |
| Opportunistic | stolen laptop, public-repo crawler, agent transcript leaked | Partial — repo content stays opaque; the lost laptop’s identity.key is a full compromise unless the disk is FDE’d. |
| Insider | departed teammate, hostile recipient | Limited — revocation stops future reads, not past ones; you must rotate values, not just recipients. |
| Targeted / APT | nation-state, ptrace on running process, supply-chain backdoor in sops | No — out of scope. Use a cloud KMS with workload identity and HSM-backed keys. |
The product target is the first two tiers, with explicit playbooks for the third. The fourth is a category error for any disk-resident secrets manager.
What envless protects against
- Secret leakage in commits. Plaintext
.envfiles are gone. Every committed file (secrets/*.env.enc,.envless/recipients) is either encrypted or a public-key list. - Casual shoulder-surfing.
envless listprints keys but never values.envless getrequires--confirm.envless execwrites secrets only into the child process’s env array — never stdout. - Lost laptop with FDE on, no
identity.keyextractable. Without the age secret key, encrypted files in the repo are bytes of ciphertext. - Drift between developer machines. The encrypted file in Git is
the canonical source. There is no “what’s in your local
.env?” divergence. - Stale agent transcripts. Logs containing
OPENAI_API_KEY=...are no longer possible: the variable is set inside the child process, not echoed to stdout byenvlessitself.
What envless does NOT protect against
- A running process inspected via
ptrace,/proc/<pid>/environ, or memory dump. Once a secret is loaded into the child’s env, the child’s memory is fair game. The optionalenvless daemonwidens this window: when installed, decrypted env stays in the daemon process’s heap for up to 60 seconds per(repo, env)pair, so a ptrace-capable attacker has a longer reach. Daemon mode is an explicit opt-in (envless daemon install) — the default install has no daemon and no resident plaintext outside the lifetime of a singlesops decrypt. See Operations → Daemon mode for the tradeoff in full. - A malicious recipient. Adding a pubkey to
.envless/recipientsgrants permanent access to every encrypted file from that point on. Revoking the pubkey only stops future encryptions from being readable. Anyone who held the key during the window can decrypt history. Rotate values, not just recipients. See Rotation. - Compromised
identity.key. Anyone with the secret key can decrypt anything encrypted to their pubkey. Treat the file as a credential of last resort:0600, never committed, FDE’d disk. - Compromised
sopsoragebinary.envlessdoes not verify signatures on the shelled-out binaries. Pin versions and use your distro’s verified packages. - Side channels in your editor / shell history.
envless get --confirmprints plaintext. So doesecho $SECRET. If you type the secret on stdin duringenvless set, yourbashhistory will rememberecho "..." | envless set. Useread -sor paste via a here-string with leading whitespace. - Supply-chain attacks on the Zig stdlib or vendored deps.
envlessships zero runtime dependencies beyond Zig 0.13.0’s stdlib + libc; age + sops are external binaries. See Audit & supply chain. - Plaintext logs from your application. If your code does
console.log(process.env), that’s on you, not onenvless. See Logging & observability.
Trust boundaries
| Boundary | Who is trusted | What can they do |
|---|---|---|
| File at rest in repo | anyone with the repo + a recipient key | nothing without the secret key |
.envless/identity.key | the developer / agent on this machine | decrypt every env they have a recipient for |
sops + age binaries on PATH | whoever installed them | full read of plaintext during decrypt |
Child process spawned by exec | inherits the env you injected | act on the secrets per your app’s code |
When NOT to use envless
A compact decision table. If any row applies, envless is the wrong
choice for at least that secret class — move it to a KMS.
| Trigger | Why envless is wrong | What to use instead |
|---|---|---|
| Team size > ~30 engineers | Recipient management becomes a full-time job; no built-in RBAC. | Vault, AWS Secrets Manager. |
| Compliance regime (PCI, HIPAA, SOC 2 with HSM, FedRAMP) | Auditor wants tamper-evident logs, HSM-backed keys, per-secret ACL. None exist here. | AWS KMS + Secrets Manager, GCP Secret Manager, Vault with HSM seal. |
| Need to revoke a single secret per-user without re-encrypting the file | sops re-encrypts at file granularity. | Vault dynamic secrets, AWS IAM. |
| Auditable access logs (“who read FOO at what time”) | Filesystem reads are not logged. | KMS access logs (CloudTrail / Cloud Audit Logs). |
| Auto-rotating credentials (DB creds that mint per-session) | envless is a static-secret tool. | Vault DB engine, AWS RDS IAM. |
| Regulated data domains — financial transactions, PHI, government | The casual / opportunistic threat model is too weak. | Domain-specific managed services. |
| Secrets accessed by services without a human checkout step | KMS workload identity (IRSA, GCP WI, Azure MI) is strictly safer than shipping identity.key to nodes. | Workload identity + KMS. |
Cryptography (age + sops)
envless does not implement any cryptography itself. It composes two
well-audited primitives. Knowing exactly which primitives matters for
your security review.
age — file-level encryption
age (Actually Good Encryption) is a small format and
tool for file encryption with multiple recipients.
- Key agreement: X25519 for recipient keys (
age1...). - Key derivation: HKDF-SHA256 from the X25519 shared secret.
- Symmetric cipher: ChaCha20-Poly1305 (AEAD).
- Header format: stanza-based, one per recipient + a single file key wrapped per recipient.
In envless, age is invoked indirectly through sops (sops handles
the data-key encryption). Identity generation goes through the
standalone age-keygen binary, which writes:
# created: 2026-01-01T00:00:00Z# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pAGE-SECRET-KEY-1XQ...envless scans for the # public key: marker to extract the pubkey
for the recipients file. See store.pubKey().
sops — per-value encryption + metadata
sops (Mozilla’s Secrets OPerationS) sits on top of age
and adds:
- Per-value encryption with a per-file data key. Keys (top-level field names) stay plaintext so file diffs remain semantic; only values are AES-GCM encrypted.
- MAC over the encrypted document for integrity (sorted-key hash).
- Multi-recipient data-key wrap: the data key is encrypted once per recipient (per age pubkey, in our case).
- Format-aware: dotenv, YAML, JSON, INI, BINARY.
envlessuses dotenv exclusively.
The exact invocations envless uses (from
zig/src/sops.zig):
sops encrypt \ --input-type dotenv \ --output-type dotenv \ --age age1...,age1... \ /tmp/envless-enc-*.env
sops decrypt \ --input-type dotenv \ --output-type dotenv \ secrets/dev.env.enc# with SOPS_AGE_KEY_FILE pointing at .envless/identity.keyDecryption reads the SOPS_AGE_KEY_FILE env var to locate the
identity. This is the only way envless references its secret key
inside a child process — never on argv.
What this means in practice
- An attacker who reads
secrets/dev.env.encfrom the repo sees the key names but never the values without a matching age secret key. - Rotating one recipient does not re-encrypt existing files —
sopsneeds an explicitsops updatekeysinvocation. See Rotation. - The data key is symmetric (AES-256) — there is no asymmetric per-value encryption. Cracking one value worth of ciphertext does not extend to others, but compromising the data key compromises the entire file. The data key is itself wrapped to each recipient’s age pubkey via X25519 + ChaCha20-Poly1305.
Audited and battle-tested
- age has had at least two formal audits (Cure53, Trail of Bits) and is built by Filippo Valsorda, ex-Go cryptography lead.
- sops is an OWASP-sponsored project, used in production by Mozilla, Adobe, and others, with security advisories published openly.
envless adds zero new crypto code on top. Its security posture is
“the union of age and sops at their currently pinned versions.”
Key rotation & compromise response
There are two kinds of rotation in envless, and conflating them is
the most common mistake.
- Rotating an identity — replacing an age keypair, e.g. when a developer leaves or a laptop is lost.
- Rotating a secret value — replacing the credential itself
(
OPENAI_API_KEY=sk-old → sk-new), e.g. when a key leaks.
Each requires a different response.
Identity rotation — recommended (sops updatekeys)
When a recipient must lose access, the safer flow is to use sops
directly. envless will gain envless re-encrypt in v0.1; until
then, this is the recommended snippet (replaces the older
decrypt-and-rewrite loop):
# 1. Edit .envless/recipients — remove the departed pubkey.$EDITOR .envless/recipients
# 2. Materialise the new recipient list for sops as a .sops.yaml at# repo root. envless does not write this file yet; you write it# once and keep it in sync with .envless/recipients.cat > .sops.yaml <<EOFcreation_rules: - path_regex: ^secrets/.*\.env\.enc$ encrypted_regex: '.*' key_groups: - age:$(awk '/^[^#]/ {printf " - %s\n", $1}' .envless/recipients)EOF
# 3. Re-wrap the data key per file. The encrypted values are NOT# re-encrypted, only the recipient-wrap headers are rewritten —# fast, atomic, and avoids decrypt/encrypt round-trips.export SOPS_AGE_KEY_FILE="$PWD/.envless/identity.key"for f in secrets/*.env.enc; do sops updatekeys --yes "$f"done
# 4. Verify the rewrap took effect — every recipient appears exactly# once per file, and the departed pubkey is GONE.for f in secrets/*.env.enc; do echo "== $f ==" grep -c '^- recipient:' "$f" # should equal recipient count grep "$OLD_PUBKEY" "$f" && echo "FAIL: stale recipient in $f"done
# 5. Commit and push.git add .envless/recipients .sops.yaml secrets/git commit -m "revoke departed recipient; sops updatekeys re-wrap"git pushImportant caveat: the removed recipient had access to the current secret values up to the moment of revocation. They retain that knowledge. Identity rotation does not protect against secrets they have already observed.
If the recipient was hostile or compromised, treat every secret they could read as compromised and follow Secret rotation below.
Identity rotation — fallback (decrypt → write → push)
If you cannot or will not use sops updatekeys (e.g. you want full
ciphertext rotation, not just a header rewrap), the portable approach
through the envless CLI works on v0.0.1 with no additional config:
for env in dev prod; do # Snapshot before for a verifiable diff. envless exec --env="$env" -- env | sort > /tmp/envless-before-$env.txt
for key in $(envless list --env="$env"); do val=$(envless get "$key" --env="$env" --confirm) printf '%s' "$val" | envless set "$key" --env="$env" done
# Snapshot after — values must match. envless exec --env="$env" -- env | sort > /tmp/envless-after-$env.txt diff /tmp/envless-before-$env.txt /tmp/envless-after-$env.txt \ || { echo "FAIL: $env diverged"; exit 1; }doneThe diff is your safety net. If diff is non-empty, you broke
something — restore from git checkout HEAD -- secrets/ and retry.
Doing this by hand without the diff check has caused real incidents.
Secret rotation (a credential leaked)
When a value itself is compromised:
# 1. Rotate at the upstream provider (Stripe dashboard, OpenAI# console, etc.). Get the new value.NEW="sk-newvalue-xyz"
# 2. Replace in envless.printf '%s' "$NEW" | envless set OPENAI_API_KEY --env=prod
# 3. Verify the change is in the encrypted file but the old value is# not anywhere in the working tree.git diff secrets/prod.env.enc | head # encrypted blob changedgrep -RIn 'sk-oldvalue' . | grep -v '.git/' && echo "STALE PLAINTEXT"
# 4. Commit and deploy.git add secrets/prod.env.encgit commit -m "rotate OPENAI_API_KEY (provider rotation)"git pushProvider rotation is the source of truth; envless set is the
mirror. Future versions (v0.2) will add envless rotate KEY adapters
that call the provider API for you.
Identity loss (laptop stolen, key file leaked)
The worst case: the secret key itself has escaped your control. Do, in order:
- Treat every secret encrypted to that pubkey as compromised. Anyone with the secret key can decrypt all current and prior encrypted files in Git history.
- Rotate every credential at the upstream provider.
- Generate a new identity (
envless initafterrm .envless/identity.key). - Edit
.envless/recipientsto replace the old pubkey with the new one. sops updatekeysevery file (see above).- Force-push history rewrites only if you must — Git history contains the encrypted files. They are unreadable without the lost key, but a defense-in-depth rewrite is fine.
What v0.0.1 does not have yet
envless panic— single command for the steps above. Coming in v0.2.envless rotate KEY— provider-aware rotation. v0.2.envless team revoke USER— recipient management via CLI. v0.1.envless re-encrypt— wrapssops updatekeys. v0.1.
Until then, the snippets above are the supported workflow.
Operational hardening checklist
Concrete things to do on every developer machine and CI runner. Treat this as a copy-paste-and-tick list.
On every developer machine
-
.envless/identity.keyischmod 0600.envless initsets this; verify after any restore from backup.Terminal window stat -c '%a' .envless/identity.key # must print "600" - Full-disk encryption is on. FileVault (macOS) / LUKS (Linux) / BitLocker (Windows). Without this, a lost laptop is a full key compromise.
- No
identity.keyin cloud backup. Exclude.envless/from Dropbox, iCloud Drive, Time Machine cloud uploads. The file is okay in encrypted local backups; it is not okay in any cloud sync that you do not control the keys for. - Shell history excludes secrets. Set
HISTCONTROL=ignorespaceand prefix anyenvless set …with a literal space when you have to type a secret on the command line. - Editor swap files aren’t syncing. Vim’s
.swp, Emacs’s#file#, IDE crash dumps. Add them to.gitignoreand confirm no sync tool grabs them from~/.local/share/. -
ageandsopsare installed from a trusted source. macOS: Homebrew taps. Linux: distro package or upstream checksums. Pin versions in your team docs.
On CI / build runners
- Use workload identity, not a checked-in
identity.key. GitHub Actions: store the key in a secret + write to the runner withchmod 0600at job start. Strip it from the workspace at job end. Never let it land in an artifact. - The CI age key is its own recipient. Do not reuse a
developer’s
identity.key. Generate one per environment, rotate independently. - CI runners do not retain state across jobs. If you self- host, ensure ephemeral runners. A long-lived runner is a key-extraction target.
- Mask
envless execoutput in CI. Most CI systems mask values that match registered secrets. Register the env-var names, not the values — or useenvless exec -- …so the values never appear in build logs at all.
Backup policy
-
identity.keylives in a password manager, not on disk alongside the repo. 1Password / Bitwarden /passare acceptable. -
recipientslives in Git. Public-key files are not sensitive; their version history is a feature — every grant is a code-reviewed PR. - The encrypted
secrets/*.env.encfiles live in Git and are backed up by your normal Git workflow. No second backup needed. - For out-of-Git backups, prefer
envless backup— it bundles.envless/recipients+secrets/*.env.enc+ a manifest into a tar.gz and excludesidentity.keyby default. Identity inclusion is gated behind an interactive prompt (or--yesfor scripts) and a multi-line stderr warning. See Operations → Backup & restore for upload destinations (rclone / restic / Google Drive via MCP), the GPG-wrap pattern for the identity key, and scheduled-backup recipes.
Audit cadence
| Cadence | What to check |
|---|---|
| Per-PR | .envless/recipients diff — every added pubkey gets a name and a date in the commit message. |
| Monthly | stat .envless/identity.key permissions, expected list of recipients matches roster, sops + age versions match team pin. |
| Quarterly | Rotate the data key by running sops updatekeys -r --yes (forces fresh data key) on every file. Free defense-in-depth. |
| On any departure | Identity rotation (see above) within 24h. Secret rotation if the role had access to production. |
Logging & observability
The most common way secrets leak after envless is in your own application logs. Defensive measures:
Application code
- Treat
process.envas a credential, not a struct. Never log it whole. Never serialise it for error reporting. Never dump it on a panic / unhandled-exception path. - Use a masking library at the logging boundary. Pino’s
redact:, Python’sstructlogdrop_keys, Go’sslogReplaceAttr— whichever fits your stack. Mask any field whose name matches^([A-Z][A-Z0-9_]*)_?(KEY|TOKEN|SECRET|PASSWORD|PWD|CREDENTIAL|API|DSN|URI|URL|CONN(ECTION)?_STR(ING)?)?$. - Block error trackers from capturing env. Sentry, Datadog, Rollbar
all collect environment by default. Disable: Sentry
beforeSendstrip, DatadogDD_LOG_TAGSfilter, Rollbarscrub_fields.
Infrastructure
- Drop env vars from container debug introspection.
docker inspectprints them by default. Use--filteror post-process before shipping to logs. - Kubernetes Pod manifests committed to Git should never have
literal env values — only
valueFrom: secretKeyRef.envless execinjects at runtime; the manifest just declares names. - Shell prompts that show env. Some Powerlevel10k / Starship
configs include arbitrary env in the prompt. Audit your
~/.zshrc/~/.config/starship.toml.
Agent / LLM workflows
This is where envless adds the most value vs naive .env, and where
operators most often break the model.
- Never paste
envless get FOO --confirmoutput into a prompt. Even with retention-zero providers, the value transits the provider’s API and may be cached. - Use
envless exec -- agent-cliinstead of pre-resolving values. The child process gets the env; the agent’s transcript does not. - Agent tools that introspect the env (e.g. “what’s in your
environment?” answer-the-tool patterns) must be disabled or
filtered.
envlesscannot help once a tool itself readsprocess.envand ships it upstream.
Integration patterns
Three concrete shapes for how envless fits into a real stack. Pick
the closest one and adapt.
Pattern 1 — Small SaaS team (3–10 engineers, one prod)
.envless/identity.key — one per developer, password-manager backup.envless/recipients — N developer pubkeys + 1 CI pubkeysecrets/dev.env.enc — dev / staging / prod, all in Gitsecrets/staging.env.encsecrets/prod.env.enc- All secrets live in
envless. No cloud secret manager. - CI uses its own age identity; production deploys
envless exec -- app. - Quarterly
sops updatekeys -rrotates the data key per env. - Rotation playbook: when an engineer leaves, identity rotation + full secret rotation for prod (because they had production access).
Cost: $0. Risk: moderate — relies on the casual / opportunistic threat tier holding.
Pattern 2 — Monorepo with N services
.envless/identity.key.envless/recipients — devs + one CI key per servicesecrets/service-foo.dev.env.encsecrets/service-foo.prod.env.encsecrets/service-bar.dev.env.encsecrets/service-bar.prod.env.enc…- One env file per
(service, env)pair. Filenames encode the ownership. - Service-specific CI keys:
foo’s CI cannot decryptbar’s production env. Achieved with separate.sops.yamlcreation_rulesper path prefix. - Recipients file groups: a comment header per group; the team reviews recipient changes per service-owner team.
- Cross-service shared secrets (e.g. shared DB) live in a
secrets/shared.*.env.encfile with the union of CI keys as recipients.
This pattern starts to strain at ~15 services. At that point,
graduate the shared-secret class to a KMS and keep envless for the
service-local class.
Pattern 3 — Cloud-native (KMS for prod, envless for dev/staging)
Dev / staging: envless, as in Pattern 1Production: AWS Secrets Manager / GCP Secret Manager / Vault- Production secrets never enter
envless. They live in the cloud provider’s KMS-backed manager. - Application code reads
process.env.STRIPE_KEYin both environments — but dev/staging gets it fromenvless exec, prod gets it injected by IRSA / GCP Workload Identity / Vault Agent sidecar. - The same code path, two injection mechanisms.
- Compliance auditor sees “production has KMS-backed, access-logged, HSM-rooted secrets” and “developer machines use a defence-in-depth encrypted-file workflow.” Both true.
This is the recommended pattern for any team subject to compliance.
The cost is one extra config layer (env-aware secret loader) and the
discipline to never envless set FOO --env=prod.
Audit & supply chain
envless is a small Zig binary built from a small dependency graph.
The audit surface is intentionally narrow.
Direct dependencies
Zero runtime dependencies beyond Zig 0.13.0’s stdlib + libc. The
subcommand dispatcher, flag parser, dotenv parser, and sops/age
shells are all hand-rolled in zig/src/ (~1760 LOC including inline
tests). No cobra, no pflag, no vendored crates — zig build runs
against the pinned compiler version and produces a static binary.
The only build-time fetch is the Zig compiler itself, pinned by
zig/.zigversion and installed in CI via goto-bus-stop/setup-zig@v2.
External binaries envless shells out to
envless is useless without these on PATH. They are not bundled.
age-keygen— from filippo.io/age. BSD-3-Clause.sops— from getsops/sops. MPL-2.0.
The shell-out commands are exactly:
age-keygen -o <path>sops encrypt --input-type dotenv --output-type dotenv --age <recipients> <file>sops decrypt --input-type dotenv --output-type dotenv <file>(withSOPS_AGE_KEY_FILE)
No other binary is invoked. No network calls.
Network behaviour
envless makes zero outbound network calls in v0.0.1. Confirm with:
strace -f -e trace=network envless exec -- true# (no connect, no sendto)If that ever changes, it will be documented and gated behind an explicit flag.
Build provenance
The release pipeline (.github/workflows/release.yml)
runs zig build release -Dversion=<tag> on ubuntu-latest
GitHub-hosted runners. The build:
- Pins Zig 0.13.0 via
goto-bus-stop/setup-zig@v2. - Cross-compiles each target with
-Doptimize=ReleaseSmall— small stripped static binary, no debug symbols. - Targets
x86_64-linux-gnu,aarch64-linux-gnu,x86_64-macos,aarch64-macos. - Produces
dist/envless_<version>_<target>.tar.gzper target plusdist/checksums.txtwith one<sha256> <basename>line per tarball.
The release tag is the only input. To reproduce a release locally:
git checkout v0.0.1cd zig && zig build release -Dversion=v0.0.1shasum -a 256 ../dist/envless_*.tar.gzVerifying a downloaded binary
zig build release emits checksums.txt next to each release. Verify:
curl -LO https://github.com/biliboss/envless/releases/download/v0.0.1/envless_v0.0.1_x86_64-linux-gnu.tar.gzcurl -LO https://github.com/biliboss/envless/releases/download/v0.0.1/checksums.txtshasum -a 256 -c --ignore-missing checksums.txtSigned binaries (cosign) are a v1.0 goal — see the roadmap.
Vulnerability response
Report security issues by email rather than a public issue. See the
GitHub repo’s SECURITY.md (will be added before v0.1). For now, file
a private security advisory.