Security & threat model
Last updated: 11 May 2026
envstore is a zero-knowledge service. The TL;DR is that the server stores ciphertext and metadata; the keys that decrypt your data live on your laptop and your CI runners. This page is the long version — what we can and cannot see, what an attacker with various footholds can and cannot do, and how we shrink the blast radius when things go wrong.
What the server stores, in plain language
- Ciphertext for every pushed version of every
.envfile. Stored in Cloudflare R2 (object storage), referenced from Postgres by a stable key. - Public recipients: the public half of each user's age keypair, plus the public half of every workspace service token's keypair. These are the keys we encrypt to on push. We never see the private halves.
- Metadata: workspace, project, group, environment, and version rows; their slugs and names; soft-delete timestamps and retention settings; member roles; invite tokens; bearer-token hashes (sha256, never the cleartext).
- Audit log: who did what, when. Actor, action, resource. No IP addresses, no User-Agent strings.
- Billing: subscription state mirrored from Paddle. Card data lives at Paddle, not here.
What the server CANNOT see
- The contents of your
.envfiles. Plaintext never leaves the CLI process. Even if our entire database and object store were exfiltrated, an attacker would have ciphertext but no key. - Your age private keys. They're generated locally (CLI
identity initandtoken create) and stored in your OS Keychain or on disk at mode 0600. They never touch our wire and never enter the browser — the dashboard does not decrypt anything. - Plaintext bearer tokens. Only sha256 hashes are stored. Stolen DB dumps don't yield working tokens.
Cryptographic primitives
We use age's hybrid encryption: X25519 for the per-recipient key wrap, ChaCha20-Poly1305 for the payload. Each push generates a fresh data key, wraps it once per recipient, and the resulting binary is what lands in R2.
We use age the protocol — not the trademark. age is a BSD-3-licensed format and library by Filippo Valsorda; we link to age-encryption.org for attribution and don't claim affiliation.
What an attacker can and cannot do
If they exfiltrate our database
They get ciphertext, public keys, metadata, hashed bearers. They cannot decrypt any .env file. They cannot impersonate a user or a service token (the hashes are one-way). They cannot move money — billing lives at Paddle.
If they exfiltrate our object storage (R2 bucket)
Same outcome: ciphertext without keys. R2 access keys can list and read every object in the bucket, but every object is encrypted to recipients whose private keys we don't hold.
If they steal a user's CLI token
They can call the API as that user. They can pull ciphertext for any workspace the user belongs to — but they still need the user's age private key (separately stored on the laptop) to decrypt it. Tokens live in the OS Keychain when available; revoke immediately with envstore logout or by removing the session from the account page.
If they steal a user's age private key
That's the worst case for one workspace. They can decrypt anything that was encrypted to that recipient — past versions encrypted to it, and future versions until the key is rotated. They still need a valid CLI token to fetch ciphertext; without one, all they have is the ability to open files an attacker has already exfiltrated.
Mitigation: rotate by registering a new identity, then run envstore rekey from a teammate's machine to re-encrypt every current version to the new recipient set. Revoke the old key in the account settings so it stops being included on future pushes.
If they steal a workspace service token
They have both the bearer AND the age private key (these travel together in CI secret storage). Same capability as a stolen user key + CLI token, scoped to one workspace. They cannot escalate: token-authenticated calls are rejected by every mutating endpoint that could mint more tokens, invite users, change member roles, modify settings, or touch billing.
Mitigation: revoke from the dashboard's Tokens page or via envstore token revoke <id>. Default 90-day expiry on every token caps the worst case if revocation is missed.
If they compromise the envstore web app at runtime
They could serve a tampered dashboard. They still cannot decrypt ciphertext because the dashboard has never had decryption code — the threat is limited to social-engineering the user (e.g., showing fake invite UI). They cannot make CLIs decrypt for them: the CLI verifies ciphertext sha256 against the API's response and decrypts locally.
How we shrink blast radius
- Soft-delete with retention. Workspace, project, and environment deletions are soft for a configurable window (default 30 days). A daily cron sweep hard-deletes past the window and nukes the matching R2 objects so retention is real, not aspirational.
- Token expiry & revocation. Workspace service tokens default to 90-day expiry (max 365). Revocation is immediate; the next API call from a revoked token returns 401.
- No token escalation. Service-token-authenticated requests cannot create more tokens, change ACLs, or touch billing. Enforced at the bearer-auth layer (
requireUserAuth), not at every endpoint — single chokepoint, easy to audit. - Audit log without PII. Every workspace action is recorded with actor + action + resource. No IPs, no UAs — the audit log answers "who, what, when" without becoming a retention liability.
- Token hashing. Bearer tokens are sha256-hashed at rest. A DB dump yields no working credentials.
- Recipient hash on every version. Each pushed version records the hash of the recipient set it was encrypted to. The CLI compares this to the current set on
rekeyto skip no-ops, and we can detect "this version is no longer reachable by anyone" if a workspace's recipient set rotates aggressively. - Trust-on-first-use cache for recipient sets. The CLI remembers every recipient set it has encrypted to per project (
~/.config/envstore/trust.json, mode 0600). First contact prints the full set so you can verify out-of-band; later additions require explicit confirmation, or--trust-newin CI. This is the guard against an active-API-compromise variant where the server injects an attacker-controlled recipient into the push response — a class of attack open against any end-to-end-encrypted vault that takes the server's recipient list at face value. - Content-Security-Policy with per-request nonce. Every page response carries a CSP that pins scripts to a one-shot nonce +
'strict-dynamic', blocks framing (clickjacking), pins form-action and base-uri to'self', and disables plugins/objects entirely. A successful XSS still can't exfiltrate to an attacker host becauseconnect-srcis locked to envstore + Paddle.
What's deliberately out of scope (for now)
- Hardware-token (FIDO2 / passkey) auth at the API layer. We support OAuth + email OTP for the web; CLI tokens are bearer. A future "step-up auth" for sensitive actions (delete workspace, billing changes) is reasonable but not yet built.
- Environment-scoped service tokens. Workspace tokens can be scoped down to specific projects today (a token minted with
--projects test,stagingcan't touch production); per-environment scoping isn't built yet. - Anomaly detection on token use. We record last-used timestamps but don't currently alert on unusual patterns ("token X just pulled from a country it's never pulled from").
Reporting a vulnerability
Send security reports to security@envstore.xyz. We respond within 72 hours. Please don't open public issues for vulnerabilities — give us a chance to fix and roll out before disclosure.
envstore is open source under AGPL v3: github.com/michael-ketzer/envstore.xyz. The crypto code lives in packages/crypto; the auth and audit code in apps/web/src/lib.