QA How-To
How to Use Cypress environment variables (2026)
Learn cypress environment variables in 2026 with cy.env, Cypress.expose, config files, CLI and CI secrets, precedence, validation, migration, and examples.
24 min read | 2,739 words
TL;DR
In modern Cypress, read sensitive configuration asynchronously with cy.env(['key']).then(({ key }) => ...). Read public browser-safe configuration synchronously with Cypress.expose('key'). Cypress.env() is deprecated.
Key Takeaways
- Use cy.env() to read only the secret or sensitive keys a test needs through the Cypress command chain.
- Use Cypress.expose() only for public values that are safe in the browser, such as feature flags and environment labels.
- Set secret values through protected OS or CI variables, and keep cypress.env.json out of source control when it contains secrets.
- Choose configuration, cypress.env.json, CYPRESS_* variables, CLI flags, or setupNodeEvents according to ownership and lifecycle.
- Validate required keys early and never print credentials, tokens, or complete resolved configuration in diagnostics.
- Migrate deprecated Cypress.env() calls to cy.env() or Cypress.expose(), then set allowCypressEnv to false.
- Remember that baseUrl is Cypress configuration, while service endpoints used by tests can be env or expose values according to sensitivity.
Cypress environment variables now have a security-focused split. Use cy.env(['key']) for secrets and other sensitive values that a test needs through the Cypress command chain. Use Cypress.expose('key') for public configuration that is safe to serialize into the browser, such as a feature flag, API version, or environment label. The older synchronous Cypress.env() API is deprecated in current 2026 Cypress.
You can supply secret values through the env block in configuration, cypress.env.json, CYPRESS_* operating-system variables, --env, or setupNodeEvents. Public values use the expose configuration block or --expose. This guide shows how to choose, validate, secure, and migrate each path without leaking credentials into source, logs, screenshots, or browser state.
TL;DR
| Value | Modern API | Typical source | Browser exposure |
|---|---|---|---|
| Password or access token | cy.env(['token']) |
CI secret or OS variable | Retrieved through privileged command access |
| Private API endpoint | cy.env(['privateApiUrl']) |
Config mapped from OS secret | Treat as sensitive |
| Feature flag | Cypress.expose('newCheckout') |
expose config or --expose |
Public and synchronous |
| Environment label | Cypress.expose('environment') |
expose config |
Public and synchronous |
| Application URL under test | Cypress baseUrl |
Cypress configuration | Normal runner configuration |
| Cloud record key | OS CYPRESS_RECORD_KEY |
CI secret | Consumed by Cypress tooling |
A current configuration and test pair looks like this:
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
env: {
apiToken: process.env.API_TOKEN,
},
expose: {
environment: 'staging',
apiVersion: 'v2',
},
e2e: {
baseUrl: 'https://app.example.test',
},
})
cy.env(['apiToken']).then(({ apiToken }) => {
cy.request({
url: '/api/me',
headers: { Authorization: `Bearer ${apiToken}` },
}).its('status').should('eq', 200)
})
expect(Cypress.expose('environment')).to.eq('staging')
1. Cypress Environment Variables in 2026
Cypress 15.10 introduced cy.env() and Cypress.expose(), and deprecated Cypress.env(). Later 15.x documentation keeps that distinction: secrets belong behind the asynchronous command API, while public configuration can be exposed synchronously. This is not merely a syntax change. It reduces the need to serialize an entire environment object into browser context when a test needs one credential.
cy.env(keys, options?) accepts an array of case-sensitive key names and yields an object. It is read-only. Missing keys have the value undefined. Supported options include log and timeout, and the Command Log records requested names rather than their values.
cy.env(['serviceToken', 'tenantId'], { log: false })
.then(({ serviceToken, tenantId }) => {
expect(serviceToken, 'serviceToken').to.be.a('string').and.not.be.empty
expect(tenantId, 'tenantId').to.match(/^tenant-/)
})
Cypress.expose() can synchronously get or set serializable public configuration. Anything exposed is accessible in browser context, including application code, scripts, and browser extensions. Never place a password, token, API key, or private database endpoint there.
Teams pinned below the version that introduced these APIs must follow the documentation matching their installed Cypress version. For a 2026 upgrade, migrate deliberately rather than copying an old Cypress.env('password') example from a blog post.
2. Choose env, expose, baseUrl, or process.env
These names represent different boundaries. process.env belongs to the Node.js process that starts or configures Cypress. The Cypress configuration file and setupNodeEvents can read it. Browser-side spec code should not assume Node's process.env exists.
env stores values intended for cy.env(), including sensitive values. expose stores public values intended for Cypress.expose(). baseUrl is a dedicated Cypress configuration option used by cy.visit() and relative URLs, so it should not be duplicated as an environment key without a real second purpose.
| Need | Correct home | Access pattern |
|---|---|---|
| Main application origin | e2e.baseUrl |
cy.visit('/dashboard') |
| Secret API token | env.apiToken from CI |
cy.env(['apiToken']) |
| Public API version | expose.apiVersion |
Cypress.expose('apiVersion') |
| Node-only secret manager lookup | setupNodeEvents |
Assign result to config.env or keep in a task |
| Cypress Cloud recording secret | OS variable | CYPRESS_RECORD_KEY |
| Install or proxy setting | Cypress system variable | Follow installation configuration, not test env |
Do not label every configuration value an environment variable. Test data, expected messages, and selectors belong in fixtures, factories, or source modules. Environment configuration should describe deployment or execution context, not become an untyped global data store.
3. Configure Secrets in cypress.config.ts
The configuration file is the right place for safe defaults and for mapping protected Node environment variables into Cypress env. Never hardcode a real secret. The example validates Node input without revealing it.
// cypress.config.ts
import { defineConfig } from 'cypress'
const required = (name: string): string => {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
export default defineConfig({
allowCypressEnv: false,
env: {
apiToken: required('API_TOKEN'),
testPassword: required('E2E_TEST_PASSWORD'),
},
expose: {
environment: process.env.TEST_ENVIRONMENT ?? 'local',
apiVersion: 'v2',
},
e2e: {
baseUrl: process.env.APP_BASE_URL ?? 'http://localhost:3000',
},
})
This fail-fast helper runs in Node before specs execute. Its error contains only the missing variable's name. It does not print the resolved secret. Decide whether all keys should be required for every invocation. Component tests may not need API credentials, so validation can be scoped by testing type or CI job rather than forcing unused secrets locally.
allowCypressEnv: false blocks the deprecated synchronous API after migration and prevents environment variables from being declared through test configuration overrides. Adopt it only after every old call and relevant plugin is migrated.
Keep public defaults in expose. A label such as local is safe. A private internal host name may reveal architecture and should be treated according to your security model rather than assumed public.
4. Use cypress.env.json for Local Overrides
A project-root cypress.env.json file can provide local environment values and overrides conflicting env values in Cypress configuration. It is convenient for developer-specific settings. Add it to .gitignore if it contains any sensitive value, and provide a sanitized example file with fake values or key names.
{
"apiToken": "local-secret-placeholder",
"testPassword": "local-password-placeholder",
"tenantId": "tenant-local"
}
# Local Cypress secrets
cypress.env.json
A safe template can be committed under a different name:
{
"apiToken": "replace-locally",
"testPassword": "replace-locally",
"tenantId": "tenant-local"
}
Do not assume gitignore erases a file that was already committed. Remove it from version control through your approved workflow, rotate every exposed credential, and inspect repository history according to incident procedures. Editing the current commit does not invalidate a leaked token.
JSON has no comments and cannot reference shell variables. If a team needs dynamic secret resolution, map process.env in cypress.config.ts or use setupNodeEvents. Keep one documented local setup path so developers do not invent inconsistent key spellings. Key names are case-sensitive when read through cy.env().
5. Set Cypress Environment Variables With OS and CLI Inputs
Prefix OS variables with CYPRESS_ or cypress_ to make them Cypress environment inputs. Cypress removes the prefix and normalizes the name. Use your shell or CI secret store rather than committing values.
export CYPRESS_apiToken='local-token-from-secret-store'
export CYPRESS_tenantId='tenant-a'
npx cypress run
The --env flag passes one or more comma-separated key-value pairs:
npx cypress run --env tenantId=tenant-a,region=us-east
Shell quoting varies, and values containing commas or other special characters are poor CLI candidates. Prefer OS or CI secrets for credentials. CLI arguments can also be visible in process listings and CI logs, so do not casually place passwords or tokens on the command line.
Some CYPRESS_* variables belong to Cypress tooling rather than test env. CYPRESS_RECORD_KEY and CYPRESS_PROJECT_ID are notable Cypress Cloud inputs and must be operating-system variables, not keys in cypress.env.json or the configuration env block. CYPRESS_INTERNAL_ENV is reserved and must not be set.
Use explicit camel-case key names in configuration and tests, and verify how OS normalization resolves them. Avoid maintaining apiUrl, API_URL, and api_url as three competing forms.
6. Read Secrets With cy.env()
cy.env() is a Cypress command, so read values inside its callback or continue chaining. Request only the keys needed by the current operation. It cannot set or mutate values at runtime.
type SecretEnv = {
apiToken?: string
tenantId?: string
}
const requireSecret = (
keys: Array<keyof SecretEnv>,
): Cypress.Chainable<SecretEnv> => {
return cy.env(keys as string[], { log: false }).then((values) => {
for (const key of keys) {
const value = values[key]
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`Required Cypress environment key is missing: ${key}`)
}
}
return values
})
}
requireSecret(['apiToken', 'tenantId']).then(({ apiToken, tenantId }) => {
cy.request({
method: 'POST',
url: '/api/test-data/reset',
headers: {
Authorization: `Bearer ${apiToken}`,
'X-Tenant-Id': tenantId,
},
log: false,
}).its('status').should('eq', 204)
})
The example's type allows undefined because missing keys return it. Runtime validation narrows the practical contract and throws a safe error naming only the missing key. Avoid echoing values in assertion messages, cy.log(), screenshots, or custom reporters.
If a token is only needed in Node, keep it there and expose a narrow cy.task() operation instead of sending the credential into browser-side test code. The task should return only the non-sensitive result needed by the test. This is a least-privilege design decision, not a universal requirement.
7. Read Public Values With Cypress.expose()
Cypress.expose() provides synchronous access to public configuration. Use it for values that are intentionally safe in browser context. It can get all exposed values, get one, set one, or merge an object. Runtime changes apply to the remainder of the current spec and do not propagate back to Node.
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
expose: {
environment: 'staging',
checkoutFlow: 'v2',
showBetaBanner: true,
},
})
// cypress/e2e/checkout.cy.ts
const environment = Cypress.expose('environment')
const checkoutFlow = Cypress.expose('checkoutFlow')
expect(environment).to.eq('staging')
expect(checkoutFlow).to.eq('v2')
if (Cypress.expose('showBetaBanner')) {
cy.get('[data-cy="beta-banner"]').should('be.visible')
}
Public values can also be supplied with --expose:
npx cypress run --expose environment=preview,checkoutFlow=v2
Current Cypress also supports suite- and test-level expose configuration overrides. Use them for intentional public variants, and remember that override lifecycle differs from arbitrary runtime mutation. Do not name a value publicToken and assume the name makes a credential safe. If possession grants access, it belongs behind secret handling.
This split is especially useful for feature tests. Public flags remain synchronous for branching in support code or specs, while authentication tokens stay inside cy.env() chains.
8. Load Dynamic Values in setupNodeEvents
setupNodeEvents runs in Node and receives the resolved Cypress configuration. It can load a value from the process or an approved secret provider, assign it to config.env, and return the updated configuration. Always return config after mutation.
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'https://app.example.test',
setupNodeEvents(on, config) {
const token = process.env.API_TOKEN
if (!token) {
throw new Error('API_TOKEN is required for end-to-end tests')
}
config.env.apiToken = token
return config
},
},
})
Use this hook when configuration depends on testing type, runtime metadata, file-based local tooling, or a secret manager client. Do not fetch a remote secret for every spec if the configuration lifecycle allows one lookup. Handle provider failure with a clear error that does not include secret payloads.
For higher isolation, keep the secret entirely in Node and define a narrow task:
setupNodeEvents(on, config) {
on('task', {
async resetTenant(tenantId: string) {
const token = process.env.API_TOKEN
if (!token) throw new Error('API_TOKEN is required')
const response = await fetch(
`${config.baseUrl}/api/test-data/tenants/${tenantId}/reset`,
{ method: 'POST', headers: { Authorization: `Bearer ${token}` } },
)
return response.status
},
})
return config
}
Protect test-support endpoints from production and authorize them appropriately. See Cypress cy.task examples for serialization and Node-boundary patterns.
9. Use Cypress Environment Variables in CI
Store secrets in the CI platform's protected secret facility and map them into the Cypress process. Keep public labels and non-sensitive flags in ordinary pipeline variables. Limit secret availability to trusted branches, environments, and jobs.
# Generic CI step, adapt secret syntax to your provider
- name: Run Cypress end-to-end tests
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}
APP_BASE_URL: https://staging.example.test
TEST_ENVIRONMENT: staging
run: npx cypress run
The configuration shown earlier maps API_TOKEN and E2E_TEST_PASSWORD from Node process.env into secret env keys. It maps the safe environment label into expose. This gives the pipeline control without putting credentials in the repository or CLI argument.
Masking is a defense, not permission to print secrets. Derived strings, JSON blobs, encoded values, screenshots, videos, HTTP headers, and third-party reporter attachments can bypass simple masking. Set log: false on sensitive requests and typing where supported, sanitize application logs, and restrict artifact access.
Use separate low-privilege test credentials per environment. Rotate them, set short lifetimes where practical, and avoid shared mutable accounts across parallel workers. The Cypress parallel testing guide covers worker-safe test data and isolation.
10. Validate Types, Required Keys, and Precedence
Environment inputs are external configuration, so validate them at a boundary. Strings such as "false" and "0" can be truthy in JavaScript depending on how a source is parsed. Convert and validate intentionally rather than relying on coercion.
const parseBoolean = (value: unknown, name: string): boolean => {
if (value === true || value === 'true') return true
if (value === false || value === 'false') return false
throw new Error(`${name} must be true or false`)
}
cy.env(['destructiveCleanup']).then(({ destructiveCleanup }) => {
const enabled = parseBoolean(destructiveCleanup, 'destructiveCleanup')
expect(enabled, 'destructive cleanup policy').to.eq(false)
})
Use a schema library if the project already has one, but do not add a dependency only to validate two strings. Report missing names and expected formats, never resolved secrets.
When the same key exists in several sources, the later override can surprise a developer. cypress.env.json overrides conflicting configuration env values. OS-prefixed variables and --env are intended for runtime overrides. setupNodeEvents can modify the resolved configuration that it returns. Keep overlapping sources rare and document the authorized override path.
Print safe resolved values only. Cypress's resolved configuration UI can help with non-sensitive settings, but do not copy full configuration into tickets or chat. Build a diagnostic that reports source names, presence, and type, not secret contents.
11. Migrate From Deprecated Cypress.env()
Inventory each old synchronous read and classify the value. A password, token, credential, or private endpoint moves to cy.env(). A feature flag, environment label, plugin option, or public service version moves to Cypress.expose(). The asynchronous secret path may require restructuring code into a command chain.
// Before: deprecated synchronous access
const token = Cypress.env('apiToken')
cy.request({
url: '/api/me',
headers: { Authorization: `Bearer ${token}` },
})
// After: secure command access
cy.env(['apiToken']).then(({ apiToken }) => {
cy.request({
url: '/api/me',
headers: { Authorization: `Bearer ${apiToken}` },
log: false,
})
})
A public flag migrates differently:
// cypress.config.ts
export default defineConfig({
expose: { checkoutFlow: 'v2' },
})
// Spec or support code
if (Cypress.expose('checkoutFlow') === 'v2') {
cy.get('[data-cy="checkout-v2"]').should('be.visible')
}
Update custom commands, support files, preprocessors, and plugins, not only spec files. Run the suite locally and in CI, then set allowCypressEnv: false to prevent regression. Search for old calls with rg 'Cypress[.]env' cypress. Review every match rather than blind replacement because the correct destination depends on sensitivity.
12. Troubleshoot and Rotate Configuration Safely
When a run fails only in one environment, first separate absence, naming, parsing, authorization, and network reachability. A missing key should fail before the first feature assertion. A present token that receives 401 or 403 needs credential, scope, tenant, or expiry investigation. A valid credential that cannot connect points to URL, DNS, proxy, certificate, or firewall configuration.
Use a safe diagnostic table:
| Symptom | Safe check | Likely action |
|---|---|---|
Key is undefined |
Report exact requested name and source presence | Fix casing or CI mapping |
| Boolean branch is wrong | Report type and accepted values | Parse true and false explicitly |
| API returns 401 | Report status and safe correlation ID | Rotate or refresh credential |
| API returns 403 | Report scope and tenant names only if non-sensitive | Fix authorization policy |
| Works locally, fails in CI | Compare source availability and job permissions | Correct protected-secret access |
| Secret appears in artifacts | Stop sharing artifacts and follow incident policy | Revoke and rotate immediately |
Rotation should not require code changes. Update the secret provider or CI value, rerun a narrow authentication check, then execute the full suite. Keep the old credential active only if the approved rotation procedure requires overlap, and revoke it promptly afterward.
Do not use production credentials for convenience. Give automation a dedicated identity with the smallest permissions and environment scope needed. Record ownership and expiry outside test code. A reliable environment-variable design includes operational lifecycle, not only a way to retrieve strings.
Interview Questions and Answers
Q: What changed about Cypress environment variables in 2026?
Modern Cypress separates secret access and public configuration. cy.env() asynchronously retrieves requested secret keys through the command chain, while Cypress.expose() synchronously accesses browser-safe values. The older Cypress.env() API is deprecated.
Q: When do you use cy.env() instead of Cypress.expose()?
I use cy.env() for passwords, tokens, API keys, database credentials, and private endpoints. I use Cypress.expose() only for values I am comfortable exposing to application scripts and browser extensions. The classification is based on impact, not convenience.
Q: Why is process.env not the normal API inside a Cypress spec?
process.env belongs to Node, while specs execute in a browser-oriented Cypress context. I read it in cypress.config.ts or setupNodeEvents, then map values to the correct Cypress boundary. Node-only secrets can stay behind cy.task().
Q: How do you fail when a required secret is missing without leaking it?
I validate presence and format at configuration startup or immediately after cy.env() yields. The error names the missing key and expected shape, not the value. I never dump the complete environment object.
Q: What is the difference between baseUrl and an environment variable?
baseUrl is a dedicated Cypress configuration option used for relative visits and requests. A separate service URL can be an env or expose value depending on whether it is sensitive. I avoid duplicating the main application URL as a generic env key.
Q: How do you provide Cypress secrets in CI?
I store them in protected CI secrets and map them to Node environment variables for the Cypress process. The configuration maps only necessary keys into env, or a Node task consumes them directly. I restrict job access and sanitize logs and artifacts.
Q: How do you migrate Cypress.env()?
I classify every value, move sensitive reads into cy.env() chains, and move public reads to Cypress.expose(). I update support code and plugins, verify local and CI runs, then set allowCypressEnv: false.
Q: Can cy.env() set a value during a test?
No. It is a read-only command. Configure values through supported sources before or during configuration resolution. Public runtime values can be changed with Cypress.expose(), but those changes are browser-visible and have spec-scoped lifecycle rules.
Common Mistakes
- Copying an old
Cypress.env()example into a current 2026 Cypress suite. - Putting tokens, passwords, or API keys in
Cypress.expose()because synchronous access is convenient. - Hardcoding real secrets in
cypress.config.tsor committingcypress.env.json. - Assuming gitignore removes a secret that was already committed instead of rotating it.
- Reading Node
process.envdirectly throughout browser-side spec code. - Passing credentials on the CLI where process listings or logs can expose them.
- Logging full request headers, resolved configuration, or environment objects during debugging.
- Treating string
"false"as a reliable Boolean without explicit parsing. - Using different casing and separators for the same logical key across config, OS, and tests.
- Forgetting to return the modified
configfromsetupNodeEvents. - Storing test data, selectors, and expected copy in global environment configuration.
- Confusing Cypress Cloud and installation variables with keys available to
cy.env().
Conclusion
Cypress environment variables in 2026 are easiest to manage when you classify values before choosing syntax. Put sensitive values behind cy.env() and protected configuration sources. Put only browser-safe flags and labels in Cypress.expose(). Keep baseUrl and Node-only operations in their dedicated configuration boundaries.
Audit the existing suite for deprecated Cypress.env() calls, migrate one value class at a time, validate required inputs without printing them, and finish by disabling the legacy API. Then document the approved local and CI sources so every environment runs the same tests with the least privilege necessary.
Interview Questions and Answers
Explain the modern Cypress environment configuration model.
I divide values by sensitivity. Secrets are configured in env and read through cy.env(), while browser-safe settings are configured in expose and read through Cypress.expose(). Dedicated Cypress options such as baseUrl stay in normal configuration.
Why did Cypress introduce cy.env()?
It lets tests request only the sensitive keys they need through privileged command access instead of depending on a synchronous global object in browser context. It is read-only and command-chain based. This supports a clearer security boundary.
How would you manage a test password in CI?
I store it in the CI platform's protected secret store, map it to a Node environment variable for the Cypress job, and assign it to config.env or consume it in a Node task. I suppress sensitive command logging and restrict artifact access.
When would you use setupNodeEvents for environment configuration?
I use it for testing-type-specific resolution, secret-provider lookup, or Node-only runtime logic. I validate the result, update config.env only when browser-side access is necessary, and return the modified config. Otherwise I keep the secret behind a narrow task.
How do you prevent missing or malformed configuration?
I validate required names and types at the configuration boundary or immediately after cy.env() yields. I parse Booleans and numbers explicitly and report only safe key names and expected formats. I do not print resolved secret values.
What is dangerous about Cypress.expose()?
Its values are intentionally available in browser context, including to application code, third-party scripts, and extensions. It must never contain a credential or any value whose disclosure creates risk. Convenience does not change sensitivity.
How do you complete a Cypress.env migration?
I inventory all calls, classify each value, rewrite secret paths as cy.env() chains, and move public paths to Cypress.expose(). I update helpers and plugins, run all testing types in CI, then set allowCypressEnv to false.
How do you debug conflicting environment values?
I identify every authorized source for the key, verify exact casing, and inspect safe presence and type information in resolved configuration. I check local cypress.env.json, OS-prefixed values, CLI flags, and setupNodeEvents mutations. I never dump the complete secret-bearing object.
Frequently Asked Questions
How do I read environment variables in Cypress 2026?
Use cy.env(['key']).then(({ key }) => { ... }) for sensitive or secret values. Use Cypress.expose('key') for public configuration that is safe in browser context.
Is Cypress.env deprecated?
Yes. Current Cypress deprecates the synchronous Cypress.env() API. Migrate sensitive values to cy.env() and public values to Cypress.expose().
Can cy.env set environment variables?
No. cy.env() is read-only and returns requested keys through a Cypress chain. Set values through configuration, cypress.env.json, CYPRESS_* OS variables, --env, or setupNodeEvents.
What should go in Cypress.expose?
Only public values such as feature flags, environment labels, API versions, and safe plugin configuration. Do not expose passwords, tokens, API keys, or private credentials.
Should cypress.env.json be committed?
Do not commit it when it contains sensitive or developer-specific values. Add it to .gitignore and commit a sanitized example file if developers need a key template.
How do I pass Cypress env values on the command line?
Use --env with comma-separated pairs, such as npx cypress run --env tenantId=tenant-a,region=us-east. Avoid putting secrets there because command lines can appear in logs and process listings.
Are Cypress environment variable names case-sensitive?
Keys requested by cy.env() are case-sensitive and must match the resolved configuration name. Standardize one spelling across configuration, OS mappings, CI, and tests.
What is allowCypressEnv?
It is a configuration option that can disable use of the deprecated Cypress.env() API. Set it to false after migration to prevent new legacy reads and reduce accidental exposure.