Secret files
Some secrets do not fit in a .env. An Android signing keystore, an Apple .p8, a .p12 certificate chain, an SSH private key, a Google service-account JSON — these are files, and teams have been passing them around in Slack for years because there was nowhere else to put them.
A secret file is a binary blob plus the two things that make it reproducible: where it goes and what mode it gets. A fresh clone plus one pull materialises everything a build needs.
The object#
| Field | Meaning |
|---|---|
| Name | Display name. Defaults to the filename. Max 120 characters. |
| Destination path | Where clients write it, relative to the project root — android/app/upload.jks. Max 400 characters. |
| Mode | POSIX mode applied on write: 0600 (default) or 0400. Nothing else. |
| Environments | One or more of development, staging, production. |
| Size, SHA-256 | Recorded server-side, so clients can diff without decrypting. |
| Description | Optional. |
How it is stored#
Envelope encryption. The bytes are sealed with a fresh AES-256-GCM key, the ciphertext goes to Convex file storage, and the key plus nonce go to WorkOS Vault. Neither store alone can read the file. Details in Data model.
The path is a trust boundary#
Every client writes this string to disk — the CLI into a repo, the GitHub Action into a runner. A path that escapes the project root turns "pull my secrets" into arbitrary file write, so validation is deliberately strict and rejects anything ambiguous rather than trying to sanitise it:
- must be relative — no leading
/, no~, noC:drive letter - forward slashes only; a backslash is rejected outright
- no
..segment, ever — it is refused, never resolved away - no NUL characters
- no segment ending in a space or a period (Windows silently strips those, which changes where the file lands)
./and//are collapsed; the canonical form is what gets stored
Reserved destinations are refused because writing them would let a secret file rewrite the tooling doing the pull, or the repository's history:
- exact:
.git,.gitignore,.envpilot - prefixes:
.git/,.envpilot/
Path uniqueness works like variable keys: the same path may exist in several environments as long as those environment sets do not overlap.
Paths are compared case-folded
macOS and Windows filesystems are case-insensitive, so .GIT/config targets
the same file as .git/config. Comparisons fold case before checking.
Who can do what#
| Capability | What it allows |
|---|---|
project.files.create | Upload a new secret file |
project.files.update | Blanket write on every in-scope file |
project.files.delete | Soft-delete and restore |
Reading follows project access plus per-file grants, the same model variables use — a developer can be granted one keystore without being granted the rest. Uploads are additionally confined to the uploader's environment scope: you cannot upload a production file if you are scoped to development.
Reading is audited, listing is not#
list and status operations are metadata-only: path, size, mode, checksum, environments. Nothing is decrypted and nothing is recorded as a download, so exploring what a project holds is cheap and quiet.
Fetching contents is a different act. Every one is decrypted server-side, returned base64-encoded, and written to the audit log against the identity or API key that asked.
Limits#
| Limit | Free | Pro |
|---|---|---|
secret_files — feature | On | On |
secret_files_limit — per org | 3 | Unlimited |
secret_files_max_bytes | 256 KB | 8 MB |
Plus one structural ceiling that no plan lifts: 1000 active files per project. It is enforced at insert, not inferred later, because every reader is bounded by the same number — the listing, the path-collision scan, and the download rate-limit burst are all sized to it. A project that could exceed it would be unlistable and unpullable.
Rate limits: 20 uploads per minute per user, and file content reads refill at 60/minute with a burst equal to the project ceiling, so a cold pull of any legal project fits in one burst. See Rate limits.
What cannot do secret files#
- No client uploads except the CLI and the dashboard. The VS Code extension materialises files during sync but has no upload command; machine credentials cannot write at all.
- Any machine credential, for writing. API keys can read files (with the
filesresource) and can never upload, edit, or delete them. - API keys by default. The
filesresource is never granted automatically; you select it explicitly when minting a key. - More than one environment per linked directory. Because a file has one path, the extension materialises the first linked environment only — a dev and a prod
google-services.jsoncannot both land in one directory.
Working with them#
| Surface | How |
|---|---|
| Dashboard | Project → Files: upload, edit, per-file permissions, trash |
| CLI | envpilot files — list, status, pull, add, get, rm |
| VS Code | Materialised automatically on sync, with the same guards as a synced .env |
| GitHub Action | files: true with project and optional files-dir |
| REST API | GET /v1/files |
| MCP | envpilot_list_files / envpilot_get_file |
See also#
- Android keystore in CI — the end-to-end walkthrough
- Data model — envelope encryption in detail
- Roles & permissions