Migrating From Cypress.env To cy.env and Cypress.expose Methods

Prepare for Cypress v16 way of handling secrets and environment variables.

Cypress v15.10.0 has announced a big switch coming in v16 - the new way of dealing with environment values and secrets. Let's see why this change is necessary, what is means for your testing code, and what the best practices for handling secrets in your tests should be.

First, let me say: Cypress end-to-end browser tests run in the browser. This is different from Playwright and different from how other test runners execute tests. When you run cypress open or cypress run, the Cypress Electron-based binary starts the Node process, launches the browser, loads the e2e spec into the browser, and lets it run. So let's imagine we have a few environment variables that we are going to use during the test:

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { defineConfig } from 'cypress'

export default defineConfig({
e2e: {
env: {
username: '[email protected]',
password: 'secret!',
apiToken: 'abc123$v101'
},
setupNodeEvents(on, config) {
on('task', {
checkUserSession({ username, token }) {
// check DB using { username, token }
}
})
}
},
})

The above env block passes variables to be used by the test, which could be something like this:

cypress/e2e/test.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('logs in', () => {
cy.visit('/')
cy.get('form')
.within(() => {
cy.get('#username').type(Cypress.env('username'))
cy.get('#password').type(Cypress.env('password'))
cy.get('#submit').click()
})
// there should be user session on the backend
cy.task('checkUserSession', {
token: Cypress.env('apiToken'),
username: Cypress.env('username')
})
})

Ok, great, so where are these values "username", "password", and "apiToken"? Are they only in the Node process, the browser process, or both? In Cypress v15, these values are in both! Here is how you can visualize where the variables are:

Cypress env variables are stored in both processes

Hmm, ok, so the spec has all these values. What about the application under test? What about every 3rd-party script loaded by the web app under test? Turns out, every app under Cypress test can access everything inside the global Cypress object:

app.js
1
2
3
4
5
6
7
8
if (window.Cypress) {
console.log(window.Cypress.env())
// {
// "username": "joe...",
// "password": "123...",
// "apiToken": "abc..."
// }
}

The above piece of code inside the website will print the username, the password, and the api token used by the test runner. Hmm, is this a good idea? I do not know why Cypress sets window.Cypress global object, it could simply set window.Cypress = true to let the app know that it is being tested, but there was no need to set the entire object.

Ok, so there are 3 different types of environment variables I see in our config file.

  • username is something the test must enter into the input field. It does not seem to be very secret, it could be a public value.
  • password is something we probably want to hide from the application code, but something the spec running in the browser needs to get at some point.
  • apiToken is NOT needed inside the browser, it could reside inside the Node.js Cypress task code. It should be kept secret from the web application, and sending it into the browser seems dangerous. Keep it inside the config Node.js process.

Refactor for safety

Even in Cypress v15 we can use the following best practice for keeping secrets really secret:

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineConfig } from 'cypress'

export default defineConfig({
e2e: {
env: {
username: '[email protected]',
},
setupNodeEvents(on, config) {
on('task', {
getSecret(name) {
// we assume that we pass all real secrets
// via process.env properties
return process.env[name]
},
checkUserSession({ username }) {
// check DB using { username, token: process.env.API_TOKEN }
}
})
}
},
})

Then the test can ask for the password value when needed, and never even ask for the "apiToken" value! The test can also "ask" for secrets using cy.task('getSecret') command, when needed. Here is the rewritten test.

cypress/e2e/test.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('logs in', () => {
cy.visit('/')
cy.get('form')
.within(() => {
cy.get('#username').type(Cypress.env('username'))
cy.task('getSecret', 'USER_PASSWORD')
.then(password => {
cy.get('#password').type(password)
})
cy.get('#submit').click()
})
// there should be user session on the backend
cy.task('checkUserSession', {
username: Cypress.env('username')
})
})

Just don't name any secrets using CYPRESS_ prefix to avoid accidentally putting the value into Cypress.env object! We start Cypress with process environment variables set like this:

1
USER_PASSWORD=secret! API_TOKEN=abc123$v101 cypress run

💡 You can use my CLI utility as-a to inject multiple blocks of environment variables at once.

If an application tries to print Cypress.env() object, it sees just the "username", since everything else is "kept" inside the Node.js process env object and not bundled into the browser spec.

Keep secrets in the process.env

app.js
1
2
3
4
5
6
if (window.Cypress) {
console.log(window.Cypress.env())
// {
// "username": "joe...",
// }
}

Cypress v16 cy.env command

So here is the big change coming to Cypress v16: instead of writing custom task "getSecret", there is a new command cy.env for retrieving the secret on demand. Your Cypress config file splits all variables into "expose" (public) and "env" (secrets) blocks:

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { defineConfig } from 'cypress'

// Cypress v16
export default defineConfig({
e2e: {
expose: {
username: '[email protected]',
},
env: {
password: 'secret!'
},
setupNodeEvents(on, config) {
on('task', {
checkUserSession({ username }) {
// check DB using { username, token: process.env.API_TOKEN }
}
})
}
},
})

Do you need the username? You can get it immediately using the new Cypress.expose static method. You want to hear a secret? Call cy.env command.

cypress/e2e/test.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const username = Cypress.env('username')

it('logs in', () => {
cy.visit('/')
cy.get('form')
.within(() => {
cy.get('#username').type(username)
cy.env(['password'])
.then(({ password }) => {
cy.get('#password').type(password)
})
cy.get('#submit').click()
})
// there should be user session on the backend
cy.task('checkUserSession', {
username
})
})

Any process env values that start with CYPRESS_ prefix are automatically set to the env block, so they are considered secrets. So you can overwrite the password specified in the Cypress config file:

1
2
3
4
# overwrite using CYPRESS_ env variable
CYPRESS_password=newSecret! npx cypress run
# overwrite using --env CLI
npx cypress run --env password=newSecret!

You can also overwrite the non-secret values using the new CLI option --expose (there is no process env setting to overwrite an exposed value)

1
npx cypress run --expose [email protected] --env password=gbPass

Migrating from Cypress.env for everything might take some time. I have a few plugins that I am migrating right now, but overall it is not too bad. Just think what you really need for your test, and if the values are secrets to be used in the browser vs secrets that should never go into the browser.

  • For non-secret values, use the expose object and the Cypress.expose static method.
  • For secrets needed by the browser test, use the cy.env command to get the value when needed
  • For "background" secrets that are not meant to be used inside the browser at all, use the process.env object and access from the Cypress config and the setupNodeEvents callback

Stay safe out there!