API Security
The REST API, MCP server, and GitHub Action all authenticate with the same key system and call the same underlying Convex actions — one enforcement core, three faces. Nothing about auth, scoping, or auditing is re-implemented per surface.
Key model#
- A new key is generated as
envpk_...and shown exactly once, at creation time. - Envpilot stores only a SHA-256 hash of the key, never the plaintext. If you lose a key, there's no way to recover it — revoke it and issue a new one.
- Every key is scoped along three dimensions: projects (all or a specific list), environments (all or a specific list), and resources (
variables,accounts,projects). A request for anything outside a key's scope is treated as if it doesn't exist. - Keys can optionally have an expiry date (
expiresAt). An expired key returns the same uniform "invalid or revoked" error as a bad or revoked key — the API never tells a caller why a key stopped working.
Revocation is immediate#
Revoking a key from the dashboard takes effect on the next request — there is no grace period and no cache to wait out. Every request re-checks the key's status (valid, scoped, not expired, not revoked, plan still entitled) before doing any work.
Why 401 vs 403 vs 404 are deliberately uninformative#
- 401 covers every reason a key doesn't work — missing, malformed, invalid, expired, or revoked — as one message. This stops an attacker from using error responses to fingerprint why a specific key failed.
- 403 means the key is valid but the org's plan or the key's own resource scope doesn't cover this call (e.g. a
variables-only key hitting/accounts, or the org isn't on a plan withpublic_api/mcp_serverenabled). - 404 means the target doesn't exist or exists but is outside the key's scope — both return the same 404. A key is never allowed to confirm the existence of a project it can't see.
Fail-loud, never partial#
If any part of a variable pull can't be completed safely, the whole request fails instead of returning something silently incomplete:
- No partial pulls. If vault decryption fails for any variable in the response, the request aborts with
503rather than returning the variables that did decrypt and omitting the ones that didn't. - No sentinel values. A variable that fails to decrypt is never represented as an empty string,
null, or placeholder — that would look like a real (wrong) value to a deploy script. - No silent truncation. A project with more than 1000 matching variables returns
422rather than a truncated list that looks complete but isn't.
Audit trail#
Every value pull is logged — which key, which project/environment, which filters were used, and the source (public-api, mcp, or cicd for the GitHub Action). Metadata-only reads (key names without values) are not audited individually to avoid log noise, since they carry no secret exposure.
Every denied request is also logged, including:
- Reuse of a revoked key
- A request outside a key's scope
- A tier/plan gate rejection after a downgrade
Revoked-key reuse is treated as the highest-signal event in this set — it's the strongest indicator of a leaked key still being used after the team responded.
Rate limiting#
Rate limits are enforced per key with a token-bucket limiter — 120/min for metadata reads, 30/min for value pulls (see API Reference for the full table). Requests using a key hash that doesn't match anything on file are rate-limited separately, per hash, to slow down brute-force key guessing.
Why there's no CORS#
The API and MCP endpoint deliberately do not send CORS headers, so browsers refuse cross-origin requests to them. API keys are bearer credentials with broad read access to your secrets — they must never be shipped in client-side JavaScript where anyone with dev tools open could read them out of a network request. If you need secrets in a browser context, that's a product decision to make deliberately, not something the public API should make easy by accident.
CI runners are ephemeral#
GitHub-hosted (and most self-hosted) runners are torn down after each job. A pulled variable only exists in that job's process environment for the duration of the run — it isn't persisted anywhere by Envpilot's side, and the GitHub Action additionally masks every value in the job log before it's exported.
Tier downgrades#
If an organization's plan is downgraded below the tier that grants public_api/mcp_server, existing keys aren't proactively deleted — the same per-request gate check simply starts returning 403 on the next call. There's no background sweep to race and no window where a downgraded org's keys keep working past the next request.