Authenticated scans (auth profiles)
Run the WCAG Audit CLI against your authenticated pages in CI — without an interactive login, and with support for multiple roles when different routes need different sessions.
Why this exists
Most accessibility issues hide behind login. The CLI's default --auth flag opens an interactive Chromium window and waits for you to log in. That works on a developer's laptop but not in CI where there's no human to click through SSO.
Auth profiles solve this. Capture a session once locally, export it as a base64 secret, and the CLI uses it in CI. Define multiple profiles when different parts of your app need different roles.
The 3-minute version
- Capture a profile locally. A Chromium window opens — you log in, the CLI captures the session.
wcag-audit auth login --profile admin --url https://your-app.com/login - Export it for CI. Pipe the base64 payload into your secret store.Then add it as a repository secret named
wcag-audit auth export --profile admin | pbcopy # macOS wcag-audit auth export --profile admin | xclip # LinuxWCAG_AUTH_ADMIN(the CLI infers the env-var name from the profile name). - Map routes to profiles. Create
wcag-audit.config.jsin your repo:js// wcag-audit.config.js export default { authProfiles: { admin: { fromEnv: "WCAG_AUTH_ADMIN" }, user: { fromEnv: "WCAG_AUTH_USER" }, }, routeAuth: [ { path: "/", auth: null }, // public { path: "/dashboard", auth: "user" }, { path: "/admin/**", auth: "admin" }, ], }; - Run in CI. Pass the secrets as env vars; the CLI picks them up automatically.yaml
# .github/workflows/wcag.yml - run: wcag-audit scan --require-auth-strict env: WCAG_LICENSE_KEY: ${{ secrets.WCAG_LICENSE_KEY }} WCAG_AUTH_ADMIN: ${{ secrets.WCAG_AUTH_ADMIN }} WCAG_AUTH_USER: ${{ secrets.WCAG_AUTH_USER }}
Profile naming
- Lowercase letters, digits, hyphens. Must start with a letter or digit.
- 1–32 chars.
admin,user,read-onlyall work. - Env-var conversion is automatic:
admin→WCAG_AUTH_ADMIN,read-only→WCAG_AUTH_READ_ONLY. Override with{ fromEnv: "MY_CUSTOM_NAME" }if you need to.
Route → profile mapping
Rules in routeAuth are matched in order; first match wins. Set auth: null for public routes (no session attached).
Glob syntax (no extra dependencies):
*— any chars except/(one segment fragment)**— any chars including/(any number of segments, including zero)?— any single non-slash char- Everything else is literal (regex meta-chars are escaped for you)
Examples:
/admin/*matches/admin/usersbut NOT/admin/users/123/admin/**matches/admin,/admin/users, AND/admin/users/123/users/?matches/users/1but NOT/users/12
Programmatic login (SSO, MFA, rotating creds)
If your login can't be captured as a static storageState — Okta / Entra / one-time codes / short-lived JWTs — write the login flow as a Playwright function in wcag-audit.config.js. The CLI runs it once per scan, captures the resulting session, and reuses it across all routes that map to that profile.
// wcag-audit.config.js
export default {
authProfiles: {
sso: {
// Runs in Playwright. Drive whatever login flow you need.
// CLI grabs storageState immediately after this resolves.
async login(page) {
await page.goto("https://your-app.com/login");
await page.fill("#email", process.env.SSO_EMAIL);
await page.fill("#password", process.env.SSO_PASSWORD);
await page.click("button[type=submit]");
await page.waitForURL("**/dashboard", { timeout: 30_000 });
},
loginTimeoutMs: 60_000, // optional, default 5 min
},
},
routeAuth: [{ path: "/**", auth: "sso" }],
};Resolution order is: env var (preferred) → programmatic hook → disk file. So you can keep the hook for local development and override with a captured env var in CI for speed.
--require-auth-strict
Catches the most common silent CI failure: your stored session expired, admin routes got redirected to /login, the scan reports 0 issues when the real failure is "no auth." With this flag, any auth-blocked route fails the scan with a clear remediation message.
wcag-audit scan --require-auth-strictStrongly recommended for CI. Pair with a 30-day session re-capture cadence (the CLI warns when a profile is older than that).
Security guidance
StorageState carries live session tokens. Treat it like any other secret:
- Use a dedicated CI test user per role, not your own account. If a CI secret leaks, you rotate one test user — not your admin login.
- Never commit
~/.wcag-audit/auth/*.jsonor paste the base64 payload anywhere outside a secret store. The CLI writes those files at0600in a0700directory specifically so they don't leak via shared dev hosts. - Rotate every 30 days. The CLI warns when a profile is older than 30d (configurable per-profile via
maxAgeDays). Sessions usually expire well before tokens do, but rotating defends against compromise too. - Scope your test users. Give them read-only access to dashboards. They're scanning for accessibility, not editing data.
- Use
--require-auth-strictin CI so a rotated/expired session fails loudly instead of silently scanning public pages.
Subcommand reference
| Command | Purpose |
|---|---|
auth login --profile X --url Y | Open Chromium, wait for login, capture as profile X |
auth list | Show saved profiles + age + origin |
auth export --profile X | Print base64 payload to stdout (pipe to clipboard / secret store) |
auth import --profile X [--base64 …] | Load a base64 payload from stdin or flag, save under profile X |
auth clear --profile X | Delete the saved profile |
Storage details
- Files:
~/.wcag-audit/auth/<name>.json - File mode:
0600(owner read/write only) - Directory mode:
0700 - Schema:
{ profile, savedAt, schemaVersion: 1, meta: { urlOrigin }, storageState: { cookies, origins } } - Base64 export wraps the entire payload (including
savedAt) so the receiving end can still warn on stale sessions in CI.
Troubleshooting
"Profile X could not be resolved"
The loader tried env var, programmatic hook, and disk file — none provided a session. Either capture locally with auth login --profile X or set the env var (or both).
"Login hook returned no cookies or origin storage"
Your async login(page) ran without throwing but didn't actually authenticate. Check that the post-login URL/selectors are correct, and that your page.waitForURL("**/dashboard") isn't timing out.
"Profile X is N days old"
The session is approaching expiry. Re-run auth login --profile X locally and re-export. Set authProfiles.X.maxAgeDays to tune the warning threshold per profile.
Routes silently scan as "0 issues"
Almost always stale auth. Run with --require-auth-strict — the CLI will list every route that hit a login wall and fail the scan.