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.

Injecting axe on the wrong side of the login navigation The left lane represents the blank document the browser sits on after a session is restored, where a request-based login runs and an injection would be wasted. An arrow labelled as a page load crosses to the right lane, the application document created by cy.visit, where the injection and the scan both belong. document A — about:blank document B — /settings/team cy.loginAs('admin') cy.request, no page load cy.injectAxe() here injected into about:blank cy.visit(route) new document created cy.injectAxe() the injection that counts cy.checkA11y('main') runs on the real DOM page load resets window cy.session() always leaves the browser on a blank page, so the visit after it is not optional.
The left lane is where most first attempts put the injection, because logging in feels like the moment the scanner should be armed.

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'] },
        });
      });
    });
  });
});
One login shared across three spec files A grid with one row per spec file. The first row shows a cache miss that runs the setup function and posts to the session endpoint, writing cookies into the session cache. The second and third rows show cache hits where only the validate call runs before the session is restored, with the per-spec cost dropping from one hundred and eighty milliseconds to twenty-four. cacheAcrossSpecs in one cypress run cy.loginAs(role) what actually runs result cost orders.cy.js cache miss setup() runs cy.request POST /api/session session written cookies + storage 180 ms team.cy.js cache hit setup() skipped validate() runs GET /api/me is 200 session restored no login UI touched 24 ms audit.cy.js cache hit setup() skipped validate() runs GET /api/me is 200 session restored no login UI touched 24 ms Forty specs at 2.4 s of form login become one API round trip plus a 24 ms validation each.
The validate call is the only per-spec cost, and it is also the only thing standing between an expired token and thirty-nine misleading failures.

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 a cy.origin() block whose callback cannot close over outer-scope variables — credentials must be passed through the args option. 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 needs cy.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
Findings per route by signed-in role Three route groups each show two columns, one for the standard user and one for the admin. The admin sees more findings on every shared route because of extra columns and row actions, and the team settings route does not exist in the standard user's application at all, so its six findings are unreachable from a single-role run. Findings at serious impact and above, by route and role standard user admin 6 4 2 0 2 5 1 3 no route 6 /orders /orders/4182 /settings/team The admin's extra columns, row menus and bulk toolbar add findings to routes the standard user also sees. Team settings is absent from the standard user's app entirely, so a single-role run can never reach it.
Role is a scan dimension in the same way locale is: the same route rendered for a different role is a different DOM with a different rule surface.

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 follows cy.session(), which puts the scanner into the blank page the session restore left behind and produces a TypeError on 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.