Scanning Authenticated Pages in Cypress a11y Runs
Almost every accessible-form, data-table, dialog and dashboard defect in a product lives behind a login, and a scanner that stops at the sign-in page audits two of the forty screens that matter. This guide is part of Cypress a11y Testing Workflows, and it covers getting the scanner past authentication without paying for a login in every spec: establishing the session once with cy.session(), seeding it through an API request instead of driving the form, re-injecting axe after the navigation that follows, keeping credentials out of the recorded artifacts, and scanning the role-specific views whose DOM differs enough to hide findings from each other.
Root Cause
Driving the login form is the obvious approach and it fails in three separate ways. It is slow: a form login that types an email, types a password, submits, waits for a redirect and waits for the dashboard to hydrate costs around two and a half seconds, and forty accessibility specs turn that into a minute and a half of a CI job spent doing nothing under test. It is coupled: every one of those forty specs now depends on the login page’s markup, so a rename of the password field turns the entire accessibility suite red for a reason that has nothing to do with accessibility. And it is repetitive in a way that hides real signal, because the login page gets scanned forty times while /settings/audit-log gets scanned never.
cy.session() exists to solve the first two. It runs a setup function once, snapshots the resulting cookies plus local and session storage under a session id, and on every later call restores that snapshot instead of running setup again. With cacheAcrossSpecs: true the snapshot survives from one spec file to the next inside a single cypress run, so a suite of forty specs performs one login. A validate() callback runs on every restore, and if its assertions fail the cached session is discarded and setup runs again — which is what keeps a short-lived token from silently poisoning spec thirty-nine.
The third failure is the one that catches people who have already adopted cy.session(). Restoring a session deliberately clears the page: after cy.session() resolves, the browser is sitting on about:blank, and a cy.visit() is mandatory before anything can be asserted. That visit creates a new document, and a new document has no injected axe in it. So the natural-looking order — log in, inject, visit, scan — is wrong in a way that produces Cannot read properties of undefined (reading 'run') on the first spec and a confident misdiagnosis of the plugin. The injection has to come after the visit, every time, because the visit is the document boundary.
Configuration
The login command below never touches the login form. It posts to the session endpoint with cy.request(), which writes the response’s Set-Cookie values into the browser’s cookie jar — exactly the state cy.session() snapshots. The session id is an array rather than a string so each role gets its own cache entry, and validate() makes a single cheap authenticated call whose status is asserted, because a cy.request() with failOnStatusCode: false and no assertion after it validates nothing at all and will happily certify an expired session.
// cypress/support/auth.js
const ROLES = {
standard: { email: 'qa.standard@example.test', secret: 'STANDARD_PASSWORD' },
admin: { email: 'qa.admin@example.test', secret: 'ADMIN_PASSWORD' },
};
Cypress.Commands.add('loginAs', (role) => {
const { email, secret } = ROLES[role];
cy.session(
['a11y', role], // array id: one cache entry per role
() => {
cy.request({
method: 'POST',
url: '/api/session',
body: { email, password: Cypress.env(secret) },
log: false, // keeps the password out of the command log
})
.its('status')
.should('eq', 201);
},
{
cacheAcrossSpecs: true, // one login for the whole cypress run
validate() {
// Asserted, not just requested: an unasserted request validates nothing.
cy.request({ url: '/api/me', failOnStatusCode: false, log: false })
.its('status')
.should('eq', 200);
},
},
);
});
The spec then treats each role as its own route list, because the routes are not the same. A standard user has no team-settings page and no audit log; an admin has both, plus extra columns, extra row actions and a bulk-selection toolbar on pages the standard user also sees. Generating one describe per role from a single map keeps the two lists visibly different in the source, which is the point — a suite that scans only the role the author happened to be logged in as looks complete and covers a fraction of the DOM.
// cypress/e2e/a11y/authenticated.cy.js
const ROUTES = {
standard: ['/orders', '/orders/4182', '/profile'],
admin: ['/orders', '/orders/4182', '/settings/team', '/settings/audit-log'],
};
Object.entries(ROUTES).forEach(([role, routes]) => {
describe(`authenticated a11y — ${role}`, () => {
beforeEach(() => {
cy.loginAs(role); // cache hit for every call after the first
});
routes.forEach((route) => {
it(`scans ${route} as ${role}`, () => {
cy.visit(route); // mandatory: cy.session() left about:blank
cy.injectAxe(); // after the visit, into the new document
// Prove the role before trusting the scan: the DOM differs per role.
cy.get('[data-signed-in-role]').should('have.attr', 'data-signed-in-role', role);
cy.get('body').should('not.have.attr', 'aria-busy');
cy.checkA11y('main', {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'] },
});
});
});
});
});
Validation
Two things need proving, and neither is visible from a green suite. The first is that setup really ran once: if the session id is accidentally unstable — a timestamp in the array, an object that serialises differently — every spec silently performs a fresh login and the only symptom is a slow job. The second is that the scan really ran authenticated, because a scan of a login page redirect produces a small, clean violation list that looks like success.
// cypress.config.js — a counter that makes a broken session id visible
const logins = {};
module.exports = require('cypress').defineConfig({
e2e: {
baseUrl: 'http://127.0.0.1:4173',
setupNodeEvents(on) {
on('task', {
'auth:login'(role) {
logins[role] = (logins[role] ?? 0) + 1;
return null;
},
});
on('after:run', () => {
// Expect exactly one entry per role for the whole run.
console.log('session setups per role:', logins);
});
},
},
});
Add cy.task('auth:login', role) as the first line of the cy.session() setup function, then read the summary at the end of the run. Seven specs across two roles must print { admin: 1, standard: 1 }. Anything higher means the cache is missing, and the number tells how badly.
npx cypress run --spec 'cypress/e2e/a11y/authenticated.cy.js'
# authenticated a11y — standard
# ✓ scans /orders as standard (1204ms)
# ✓ scans /orders/4182 as standard (742ms)
# ✓ scans /profile as standard (688ms)
# authenticated a11y — admin
# 1) scans /settings/team as admin
# 2 accessibility violations were detected
# color-contrast table.team-grid td.role-badge
# aria-required-children div[role="tablist"]
#
# session setups per role: { standard: 1, admin: 1 }
The role assertion in the spec is the other half of the proof. cy.get('[data-signed-in-role]').should('have.attr', ...) fails immediately if the visit was redirected to the sign-in page, so an authentication problem reports itself as a missing attribute rather than as an unexpectedly accessible page. Without that assertion, a broken session produces a suite that gets faster and greener at the same time, which is the worst possible failure signature.
Edge Cases and Conditional Guards
- Third-party identity providers. If the login lives on another origin,
cy.request()cannot complete the handshake and the browser has to go there, which means acy.origin()block whose callback cannot close over outer-scope variables — credentials must be passed through theargsoption. The cheaper answer for an accessibility run is a backend test endpoint that mints a session for a seeded user, so the browser never leaves the application origin and the identity provider’s own conformance stays its own problem. - Tokens in storage rather than cookies.
cy.session()snapshots local and session storage as well as cookies, but storage is per-origin and the origin has no storage until it has been visited. A token-based app therefore needscy.visit('/')inside the setup function before the token is written, and that visit is also a document load — one more reason the injection belongs after the final visit rather than anywhere in the login path. - CSRF and expiry. A session endpoint protected by a double-submit CSRF token needs a
cy.request()GET first to obtain the token, then the POST carrying it. And an access token with a five-minute lifetime will expire partway through a long run:validate()catches it and re-runs setup, but only if the validation asserts a status rather than merely issuing a request, and only if the endpoint it calls genuinely requires the token.
Pipeline Impact
Credentials reach the run as environment variables — GitHub Actions masks a secret in the log output, but nothing masks it inside a Cypress artifact. The command log embedded in every screenshot renders the body of each cy.request(), video frames record every keystroke typed into a password field, and test replay captures both. { log: false } on the request, an API-based login instead of a form login, and video: false on the authenticated project remove all three exposures. Treat any leaked artifact as a live credential regardless, which is the argument for a seeded test tenant with rotating passwords rather than a shared staging account that also has real data in it.
Fork pull requests get no secrets, so an authenticated accessibility job that runs on them fails with a missing password and trains everyone to ignore a red check. Guard the job on the head repository and let forks run the signed-out specs only; the same-repo condition below is the minimal form, and the broader trade-offs of gating on secrets live in the guide on configuring GitHub Actions for automated WCAG checks. One consequence worth deciding early: a job that is skipped on forks cannot also be a required status check without blocking every fork pull request permanently, so the authenticated scan belongs in the advisory set while the signed-out scan carries the requirement, a split explained in requiring accessibility status checks in branch protection.
# .github/workflows/a11y-authenticated.yml
name: a11y-authenticated
on: [pull_request]
jobs:
scan-behind-login:
# Forks receive no secrets; running there produces a meaningless red check.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Seed the test tenant
run: npm run db:seed:a11y # creates qa.standard and qa.admin, nothing else
- name: Scan both roles
run: npx cypress run --spec 'cypress/e2e/a11y/authenticated.cy.js'
env:
CYPRESS_STANDARD_PASSWORD: ${{ secrets.A11Y_STANDARD_PASSWORD }}
CYPRESS_ADMIN_PASSWORD: ${{ secrets.A11Y_ADMIN_PASSWORD }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-authenticated-findings
path: cypress/reports/ # screenshots deliberately excluded from the upload
retention-days: 7
Two roles is usually the right starting number — the lowest-privilege role that can reach the product’s core flows, and the highest-privilege role whose screens are densest. Adding a third only pays for itself when that role has routes or components the other two never render, and each extra role multiplies the run time by the size of its route list. When several roles do differ, keep the route maps in one module so a reviewer can see the difference at a glance, and give each role its own spec pattern so a failure names the role in the job output. The tag list and rule overrides stay shared across all of them, exactly as described in the axe-core configuration and setup guide, so no role is silently held to a weaker standard.
Common Pitfalls
- Injecting axe before the
cy.visit()that followscy.session(), which puts the scanner into the blank page the session restore left behind and produces aTypeErroron the scan. - Building the session id from anything unstable — a timestamp, a random suffix, an object whose key order varies — so every spec misses the cache and performs a full login while the suite still passes.
- A
validate()that issues a request without asserting its status, which never invalidates an expired session and lets a whole run scan sign-in redirects instead of the application. - Driving the login form so the password is typed into a field that the video recorder and the command-log screenshots both capture, turning a build artifact into a credential store.
- Scanning only the highest-privilege role on the grounds that it sees the most screens, which skips every view where a control is hidden or replaced for a lower-privilege user.
- Sharing one staging account between the accessibility suite and manual testers, so a session invalidated by a human sign-in fails the pipeline with an error that looks like a scanner bug.
FAQ
Can the same session be reused for the component specs as well? No, and it is not needed. Component specs never authenticate — they render a component into an isolated harness with props supplied by the spec, so the signed-in state is just another prop or context value. That separation is the reason component specs run in a much shorter job with no database and no secrets, as set out in the guide on configuring cypress-axe for component testing.
Does seeding the session through an API skip real accessibility coverage of the login flow? It skips it deliberately, and the login page then needs one spec of its own that does drive the form: a scan of the empty state, a scan with a validation error rendered, and an assertion that the error is announced. That is a better test of the login page than forty incidental scans of it, and it keeps the flow’s own defects attributable to one spec instead of being spread across the whole suite.
How does this interact with cypress run --parallel across several machines?
Each machine is a separate cypress run, so cacheAcrossSpecs caches within a machine and every machine performs its own login — three containers means three logins, not one, which is fine. What is not fine is several machines sharing one test account whose backend enforces a single active session, because each new login invalidates the others and validate() will thrash. Give each parallel role a distinct seeded account, or seed one account per container index — the same per-shard isolation problem covered in sharding axe-core scans across parallel CI jobs.
Related
- Cypress a11y Testing Workflows — the parent guide on injection, scoping and the violation reporter this spec relies on.
- Configuring cypress-axe for Component Testing — the unauthenticated inner loop that runs before this job.
- CI/CD Integration & Automated Quality Gating — the section covering secrets, fork policy and how this job’s exit code gates a merge.