Scanning Authenticated Application States

To scan role-gated routes reliably, capture a Playwright storage_state once, inject it into CI as an encrypted secret, and load it into a fresh browser context before every scan — never script a live login in the hot path of the gate. This removes the single flakiest, most rate-limited dependency from your accessibility pipeline and lets the scanner evaluate the exact authenticated surface a real user sees.

This guide sits under the baseline scanning setup guide within the accessibility compliance baseline for enterprise web ops section. It is deliberately distinct from the single-journey gate: that page is about the decision logic that blocks a deploy; this page is only about getting a valid, non-flaky authenticated session in front of the scanner so those role-gated routes can be evaluated at all.

When This Applies

Reach for stored session state when the surfaces you must audit live behind authentication or role gates. It applies when:

  • The routes that carry your most complex components — dashboards, admin consoles, billing flows — are unreachable without a logged-in session.
  • Different roles (member, admin, support) render materially different DOMs that each need their own scan.
  • A live login step in the job is intermittently failing, hitting rate limits, or triggering MFA that a headless runner cannot satisfy.

If your critical surface is fully public, you do not need any of this. And if a stored session scans clean on a page you know is broken, check DOM stabilization in Playwright headless scanning workflows before suspecting the session — an unsettled page fails the same way regardless of auth.

Minimal Reproducible Example

The anti-pattern logs in inside every scan. It works on the developer’s machine and then fails in CI on rate limits, MFA prompts, or a slow auth redirect — and it re-authenticates once per route, multiplying the fragility across the whole run.

# scan_naive.py — live login inside every scan. Flaky and rate-limited.
from playwright.sync_api import sync_playwright

def scan_authenticated(url):
    with sync_playwright() as p:
        page = p.chromium.launch().new_page()
        # Every route re-runs the full login: N routes = N credential exchanges,
        # each one a chance to hit a rate limit, an MFA challenge, or a 302 race.
        page.goto("https://staging.example.com/login")
        page.fill("#email", "admin@example.com")
        page.fill("#password", "hunter2")     # secret embedded in code, re-sent every run
        page.click("button[type=submit]")
        page.goto(url)                        # may fire before the auth cookie is set
        return page.evaluate("axe.run()")

Beyond the flakiness, the credential is embedded in source and the post-login navigation can race the auth cookie, so the scan sometimes lands back on the login screen and reports it as clean.

Correct Implementation

Split authentication from scanning. A setup step authenticates once and writes storage_state to disk; the scan loads that state into a fresh context and never touches the login form. In CI the state file is materialized from an encrypted secret, so no live credential exchange happens during the gate.

# auth_setup.py — run ONCE (locally or in a dedicated setup job) per role.
from playwright.sync_api import sync_playwright

def capture_state(role: str, email: str, password: str) -> None:
    """Log in a single role and persist its session to a storage_state file."""
    with sync_playwright() as p:
        context = p.chromium.launch().new_context()
        page = context.new_page()
        page.goto("https://staging.example.com/login", wait_until="networkidle")
        page.fill("#email", email)
        page.fill("#password", password)
        page.click("button[type=submit]")
        # Wait for an authenticated landmark so the cookie/token is actually set
        # before we serialize — persisting too early captures a logged-out state.
        page.wait_for_selector("main#dashboard", state="visible")
        context.storage_state(path=f"auth/{role}.state.json")   # cookies + localStorage
        context.close()
# scan.py — the gate loads a stored session; it never logs in.
import json
import os
from pathlib import Path
from playwright.sync_api import sync_playwright

def load_state(role: str) -> str:
    """Materialize the storage_state from a CI secret, falling back to a local file.

    In CI the raw JSON is passed via an encrypted env var (e.g. AUTH_STATE_ADMIN)
    so no plaintext session artifact is ever committed to the repo.
    """
    secret = os.environ.get(f"AUTH_STATE_{role.upper()}")
    path = Path(f"auth/{role}.state.json")
    if secret:
        path.parent.mkdir(exist_ok=True)
        path.write_text(secret, encoding="utf-8")
    if not path.exists():
        raise RuntimeError(f"No session for role '{role}' — run auth_setup or set the secret")
    return str(path)

def scan_role_route(role: str, url: str) -> dict:
    with sync_playwright() as p:
        browser = p.chromium.launch()
        # Fresh context seeded with the stored session — the scan is authenticated
        # from its very first request, with no login navigation in the hot path.
        context = browser.new_context(
            storage_state=load_state(role),
            viewport={"width": 1280, "height": 800},
            locale="en-US",
        )
        page = context.new_page()
        page.goto(url, wait_until="networkidle")
        page.wait_for_selector("main#dashboard", state="visible")   # confirm still authed
        page.add_script_tag(path="node_modules/axe-core/axe.min.js")
        results = page.evaluate(
            "() => axe.run(document, {runOnly: {type:'tag', values:['wcag2a','wcag2aa','wcag22aa']}})"
        )
        browser.close()
        return results

Storing one state file per role keeps each role’s scan isolated, so an aria-required-attr or label failure in the admin console is never confused with the member view. Which rules run against each authenticated surface is still governed by the axe-core enterprise configuration, and the security posture of storing session artifacts connects to the security and privacy framework integration guidance.

Pipeline Integration Note

Stored session state is what lets the authenticated entries in the route manifest actually run. In the broader baseline described by the parent baseline scanning setup guide, each route carries an auth key naming a role; the scanner maps that key to the matching storage_state and loads it into the context before scanning. A short-lived setup job refreshes the state files on a schedule and publishes them as encrypted secrets, so the gate jobs stay fast and stateless while the fan-out across the batch validation architecture scales role-gated scans the same way it scales public ones. The auth concern is isolated to one job; every downstream scan just consumes a session.

Gotchas

  • Session expiry silently logs the scanner out mid-run. A captured token has a lifetime, and a state file older than it lands the scan on the login page, which reports as a suspiciously clean surface. Assert an authenticated landmark is present after navigation and fail loudly if it is missing, and refresh state files on a cadence shorter than the token TTL.
  • Multi-tenant apps bind the session to a specific tenant. A storage_state captured for tenant A will not grant access to tenant B’s routes, and a mismatch may redirect to a marketing shell that passes an empty scan. Capture one state per tenant-plus-role combination and pin the tenant in the URL or host header so the scan cannot drift onto the wrong shell.
  • State files are credentials and must never be committed. Cookies and local-storage tokens in a storage_state file grant real access. Keep them out of the repo, inject them only via encrypted secrets, and scope the seeded accounts to the minimum role needed so a leaked artifact exposes as little as possible.

Frequently Asked Questions

Why reuse storage_state instead of logging in each run?

A live login inside the scan adds a rate-limited, MFA-prone, credential-dependent step to every route, and it re-authenticates once per URL, so a run over many routes multiplies the chance of an intermittent auth failure. Capturing the session once and replaying its storage_state removes that step from the hot path entirely, so the scan is authenticated from its first request without any credential exchange during the gate.

How do I inject the session into CI without committing it?

Store the raw storage_state JSON as an encrypted CI secret, one per role, and have the job write it to disk at runtime before the scan reads it. The file exists only for the life of the runner and never enters version control, which keeps the live cookies and tokens out of the repository while still giving the scanner a valid session.

How do I scan several roles that render different DOMs?

Capture a separate storage_state file for each role and key it by role name, then map each authenticated route in your manifest to the role it requires. The scanner loads the matching session into a fresh context per role, so the admin console and the member view are evaluated as the distinct surfaces they actually are rather than blurred into one scan.

My authenticated scan reports a clean page that is clearly broken. What happened?

The session almost certainly expired and the navigation landed on the login screen, which carries few findings and reads as clean. Add an assertion that an authenticated landmark is visible after each navigation and fail when it is absent, and shorten the refresh cadence of your state files so a stale token cannot silently downgrade the scan to an unauthenticated page.