A UI login test should log in through the UI. Most other tests should not.
If 80 tests begin by typing the same username and password, they are repeatedly testing authentication while adding redirects, animations, third-party identity pages, and network timing to unrelated scenarios.
Cypress can establish the session over HTTP with cy.request(), store it with cy.session(), and continue in the real browser with the same cookies.
I am a Cypress Ambassador. This article uses a synthetic application and reflects my own technical evaluation.
The smallest useful command
In Cypress 16, sensitive environment values should be read asynchronously with cy.env(). The old synchronous Cypress.env() API was removed.
// cypress/support/commands.ts
Cypress.Commands.add('loginByApi', () => {
cy.env(['supportUser', 'supportPassword'], { log: false }).then(
({ supportUser, supportPassword }) => {
cy.session(
['support-user', supportUser],
() => {
cy.request({
method: 'POST',
url: '/api/auth/login',
body: {
username: supportUser,
password: supportPassword,
},
log: false,
})
.its('status')
.should('eq', 200)
},
{
cacheAcrossSpecs: true,
validate() {
cy.request('/api/auth/me')
.its('body.role')
.should('eq', 'support')
},
},
)
},
)
})
Then the spec spends its browser time on the feature being tested:
describe('support dashboard', () => {
beforeEach(() => {
cy.loginByApi()
cy.visit('/support')
})
it('filters cases assigned to me', () => {
cy.findByRole('button', { name: 'Assigned to me' }).click()
cy.findAllByTestId('case-row').should('have.length.greaterThan', 0)
cy.findAllByTestId('case-owner').each(($owner) => {
expect($owner).to.contain.text('Me')
})
})
})
Why the browser is already authenticated
cy.request() does not maintain a separate HTTP-client cookie jar. Before sending a request, Cypress attaches matching cookies from the browser. When a response contains Set-Cookie, Cypress writes those cookies into the browser jar.
That shared boundary makes this flow possible:
POST /api/auth/login
↓ Set-Cookie
browser cookie jar
↓
cy.visit('/support')
cy.session() snapshots the resulting cookies, localStorage, and sessionStorage. On later tests, Cypress can restore the snapshot instead of running the setup again.
The session identifier matters. ['support-user', supportUser] prevents Cypress from confusing this session with a different user or login strategy.
Choose the cache boundary deliberately
cacheAcrossSpecs: true can remove repeated authentication work across spec files in the same run. It is appropriate only when the cached identity is safe to reuse. If a test changes that user's role, password, tenant, or server-side session, either give the scenario a different session key or avoid cross-spec caching.
The key should describe every input that can change the authenticated state:
cy.session(
['support-user', tenantId, locale, authStrategy],
establishSession,
{ validate: validateSession, cacheAcrossSpecs: true },
)
Do not include a password in the identifier. Session identifiers can appear in diagnostic output; use non-secret dimensions and let the setup callback read credentials privately.
cacheAcrossSpecs is not a distributed cache. Parallel CI machines or Docker containers do not share the saved browser session. Each runner must be able to establish and validate its own session, and the test account must tolerate that concurrency. If parallel containers mutate one shared user, isolation problems can look like authentication flake.
Always validate the restored session
A cached session can become invalid because the server expired it, the test environment reset, or the authorization changed. Without validate(), the next failure may appear far from authentication.
The validation request should check something meaningful. A generic 200 from /health proves nothing about the user. /api/auth/me can verify both authentication and the expected role.
If validation fails, Cypress reruns the session setup.
Secrets are safer, not magically safe
cy.env() keeps configured values in the Node process until the requested keys are yielded. Cypress logs key names, not values. Once your callback receives the object, however, those strings are ordinary JavaScript values.
Avoid this:
cy.env(['supportPassword']).its('supportPassword').should('not.be.empty')
A failed assertion can print the value. Use { log: false }, avoid assertions on secrets, do not interpolate them into error messages, and scope test credentials narrowly.
Public, non-sensitive settings such as an environment label can use Cypress.expose() and synchronous access. Credentials should not.
What API login does not test
This pattern intentionally skips:
- form validation;
- password-manager behavior;
- identity-provider redirects;
- browser CORS behavior for the login call;
- visual and accessibility behavior of the login page.
Keep a small dedicated authentication suite for those responsibilities. Use API login for tests whose subject is something else.
This also makes triage clearer:
| Failure | Likely boundary |
|---|---|
| Login API rejects credentials | Fixture, secret, or authentication service |
| Session validation fails after restore | Expired or mutated server-side session |
/support redirects after validation passed |
Browser routing or authorization integration |
| Filter assertion fails | The support-dashboard behavior under test |
Also remember that cy.request() originates in the Cypress Node process. It does not appear in the browser Network panel, and cy.intercept() cannot spy on or stub it. Use cy.intercept() for requests made by your application.
The decision rule
Use the UI when login is the behavior under test. Otherwise, establish the prerequisite through the narrowest supported API, validate the identity, and spend the browser run on the feature you actually want to evaluate.
That is faster, but more importantly, it makes failures easier to classify.
Would a failed login in your current suite identify an authentication regression—or merely prevent fifty unrelated tests from starting?
Top comments (0)