Summary
Access presets such as Variables only are documented to grant only read/write on environment variables. The sibling bootstrap route was explicitly written so that “a machine credential never receives that root key.”
GET /api/v1/projects/:projectRef/envvars authenticates with read:envvars, then returns resolveVariablesForEnvironment(), which injects the environment’s root apiKey as TRIGGER_SECRET_KEY for every non-DEVELOPMENT environment.
A leaked Variables-only additional key therefore becomes the environment root secret (tr_prod_… / tr_stg_… / tr_preview_…): trigger, deploy, mint public JWTs, read all runs.
This is not the packet path-traversal advisories and not GHSA-wpjq (UAT caps).
Product boundary
From docs/apikeys.mdx:
Access presets define what a key can do.
Variables only — Read and write environment variables in this environment.
No restrictions — Full access to the environment.
From environmentVariableApiAccess.server.ts:
API-key exchanges must reuse this value instead of exposing the environment's root credential.
From api.v1.projects.$projectRef.$env.ts:
A machine credential never receives that root key.
OSS fallback cannot issue restricted presets (prepareApiKeyPolicy only allows FULL_ACCESS). The bug is in the shared route used by Cloud, where those presets exist.
Vulnerable code
apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts (loader):
const authResult = await authenticateApiKeyWithScope(request, {
action: "read",
resource: { type: "envvars" },
});
const variables = await resolveVariablesForEnvironment(
envVarEnvironment,
envVarEnvironment.parentEnvironment ?? undefined
);
return json({
variables: variables.reduce((acc, variable) => {
acc[variable.key] = variable.value;
return acc;
}, {}),
});
resolveBuiltInProdVariables (environmentVariablesRepository.server.ts:1186-1190):
{ key: "TRIGGER_SECRET_KEY", value: parentEnvironment?.apiKey ?? runtimeEnvironment.apiKey }
resolveBuiltInDevVariables does not inject the secret (DEV is not affected).
Intended consumer of that injection is the worker (dequeue injects env into the task container), not this public HTTP dump.
Guarded sibling
GET /api/v1/projects/:ref/:env (CLI bootstrap):
apiKey: apiKeyForProjectEnvironmentBootstrap(authenticationResult, environment.apiKey)
returns presentedApiKeyFromAuthentication(...) for an API-key caller — the additional key itself, never the root.
GET /api/v1/projects/:ref/envvars/:slug uses getEnvironmentWithRedactedSecrets and does not merge builtins.
Impact
- Confidentiality of the environment root secret and of every secret stored as an env var (the dump returns plaintext values, including
isSecret entries resolved from the secret store).
- Integrity: the dumped root key can trigger tasks, complete waitpoints, rotate webhooks, mint
admin public JWTs.
- Prerequisites: a valid additional key whose scopes include
read:envvars bound to a STAGING/PRODUCTION/PREVIEW environment (Cloud Variables-only preset, or any custom policy with that action).
Reproduction
Independent of a live cluster: node poc/TD-01-envvars-root-key/source-probe.mjs (must print BYPASS_PRESENT).
Live lab (self-host v4.5.12 at 127.0.0.1:8640, 2026-08-23). OSS cannot issue Variables-only presets; the lab inserted a Cloud-shaped additional key (scopes=["read:envvars"]) into api_keys and enabled additionalApiKeyLookupEnabled. The dump / bootstrap / trigger handlers are the real shared routes.
| Step |
Action |
Observed |
Evidence |
| A0 |
GET /api/v1/projects/$REF/envvars no token |
401 |
dynamic/raw/td01_a0_anon.json |
| A1 |
Same URL, Authorization: Bearer $RESTRICTED |
200, variables.TRIGGER_SECRET_KEY is the env root tr_prod_UcTW… (not the additional key) |
dynamic/raw/td01_a1_restricted_dump.json |
| N1 |
POST /api/v1/tasks/$TASK/trigger with $RESTRICTED |
403 unauthorized |
dynamic/raw/td01_n1_restricted_trigger.json |
| N2 |
GET /api/v1/projects/$REF/prod with $RESTRICTED |
200, apiKey equals the additional key (tr_prod_sk_…) |
dynamic/raw/td01_n2_bootstrap.json |
| A2 |
POST .../trigger with the dumped root |
422 No worker group found (auth passed; lab has no supervisor) vs N1's 403 |
dynamic/raw/td01_a2_dumped_root_trigger.json |
| N3 |
Org B root GET Org A envvars |
404 Project not found |
dynamic/raw/td01_n3_orgb_dump.json |
Script: dynamic/deploy/seed_and_verify.py. Evidence class: full_deployment_confirmed. Write-up: dynamic/triggerdev-dynamic-note-2026-08-23.md. Cloud itself was not contacted.
Summary
Access presets such as Variables only are documented to grant only
read/writeon environment variables. The sibling bootstrap route was explicitly written so that “a machine credential never receives that root key.”GET /api/v1/projects/:projectRef/envvarsauthenticates withread:envvars, then returnsresolveVariablesForEnvironment(), which injects the environment’s rootapiKeyasTRIGGER_SECRET_KEYfor every non-DEVELOPMENT environment.A leaked Variables-only additional key therefore becomes the environment root secret (
tr_prod_…/tr_stg_…/tr_preview_…): trigger, deploy, mint public JWTs, read all runs.This is not the packet path-traversal advisories and not GHSA-wpjq (UAT caps).
Product boundary
From
docs/apikeys.mdx:From
environmentVariableApiAccess.server.ts:From
api.v1.projects.$projectRef.$env.ts:OSS fallback cannot issue restricted presets (
prepareApiKeyPolicyonly allowsFULL_ACCESS). The bug is in the shared route used by Cloud, where those presets exist.Vulnerable code
apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts(loader):resolveBuiltInProdVariables(environmentVariablesRepository.server.ts:1186-1190):resolveBuiltInDevVariablesdoes not inject the secret (DEV is not affected).Intended consumer of that injection is the worker (dequeue injects env into the task container), not this public HTTP dump.
Guarded sibling
GET /api/v1/projects/:ref/:env(CLI bootstrap):returns
presentedApiKeyFromAuthentication(...)for an API-key caller — the additional key itself, never the root.GET /api/v1/projects/:ref/envvars/:slugusesgetEnvironmentWithRedactedSecretsand does not merge builtins.Impact
isSecretentries resolved from the secret store).adminpublic JWTs.read:envvarsbound to a STAGING/PRODUCTION/PREVIEW environment (Cloud Variables-only preset, or any custom policy with that action).Reproduction
Independent of a live cluster:
node poc/TD-01-envvars-root-key/source-probe.mjs(must printBYPASS_PRESENT).Live lab (self-host
v4.5.12at127.0.0.1:8640, 2026-08-23). OSS cannot issue Variables-only presets; the lab inserted a Cloud-shaped additional key (scopes=["read:envvars"]) intoapi_keysand enabledadditionalApiKeyLookupEnabled. The dump / bootstrap / trigger handlers are the real shared routes.GET /api/v1/projects/$REF/envvarsno tokendynamic/raw/td01_a0_anon.jsonAuthorization: Bearer $RESTRICTEDvariables.TRIGGER_SECRET_KEYis the env roottr_prod_UcTW…(not the additional key)dynamic/raw/td01_a1_restricted_dump.jsonPOST /api/v1/tasks/$TASK/triggerwith$RESTRICTEDunauthorizeddynamic/raw/td01_n1_restricted_trigger.jsonGET /api/v1/projects/$REF/prodwith$RESTRICTEDapiKeyequals the additional key (tr_prod_sk_…)dynamic/raw/td01_n2_bootstrap.jsonPOST .../triggerwith the dumped rootNo worker group found(auth passed; lab has no supervisor) vs N1's 403dynamic/raw/td01_a2_dumped_root_trigger.jsonProject not founddynamic/raw/td01_n3_orgb_dump.jsonScript:
dynamic/deploy/seed_and_verify.py. Evidence class:full_deployment_confirmed. Write-up:dynamic/triggerdev-dynamic-note-2026-08-23.md. Cloud itself was not contacted.