a11yhawk
v0.7.0
Published
Open-source, self-hostable web accessibility scan engine: Playwright capture, Lighthouse audits, and BYOK LLM analysis producing structured WCAG reports.
Downloads
2,611
Maintainers
Readme
A11yHawk
Open-source, self-hostable web accessibility scan engine. A11yHawk loads a page in a real browser, runs deterministic Lighthouse accessibility audits, and (optionally) has an LLM of your choice analyze the full page context - screenshot, accessibility tree, and sanitized HTML - against every WCAG criterion for the version and level you select. The result is a structured JSON report, a human-readable markdown report, a self-contained HTML report page, and an annotated screenshot with issues boxed on the page.
Runs entirely on your own infrastructure. No accounts, no telemetry, no phone-home; the only network traffic is the page you scan and the LLM endpoint you configure (and none at all in Lighthouse-only mode beyond the page itself).
Status: pre-1.0. The library API, CLI, and HTTP server mode are functional and tested. The API may change between minor versions until 1.0; see the CHANGELOG.
How it works
validate URL
|
v
Playwright capture
(screenshot, a11y tree, HTML)
|
v
Lighthouse audit
(deterministic; accessibility + optional performance)
|
v
LLM analysis
(BYOK, optional; skipped in Lighthouse-only mode)
|
v
parse and score
(always recomputed by the engine)
|
v
reports
(JSON, markdown, HTML, annotated screenshot)- Playwright capture: full-page screenshot (tiled to your LLM provider's image limits), Chrome DevTools accessibility tree, and HTML sanitized down to its accessibility-relevant structure.
- Lighthouse: accessibility-category audits in a subprocess, reusing the same browser over CDP. Findings are mapped to WCAG criteria and fed into the LLM prompt for cross-referencing. Opt in to the performance category and both run from the same page load (see Lighthouse performance category).
- LLM analysis (optional): the model receives the screenshot tiles, compact accessibility tree, sanitized HTML, Lighthouse findings, and a condensed matrix of every WCAG criterion for your chosen version + level, and returns structured issues with remediation guidance. Scores and statistics are recomputed by the engine from the actual findings, never trusted from the model (see Security).
- Lighthouse-only mode: omit the LLM config and you get a deterministic, no-API-key scan in a few seconds.
Requirements
- Node.js >= 20 (ESM-only package; use
import) - Chromium for Playwright (one-time):
npx playwright install chromiumfrom a project with a11yhawk installed, ornpx [email protected] install chromiumfrom anywhere else (browser downloads are pinned per Playwright version, and an unpinnednpx playwrightoutside a project resolves the registry's latest) - For LLM mode: an API key for OpenRouter or any OpenAI-compatible endpoint, or an Anthropic API key to call the Claude API directly (see Anthropic provider). You bring your own key and pay your own token costs; A11yHawk adds nothing on top.
Install
npm install a11yhawk
npx playwright install chromiumQuick start
Lighthouse-only (no API key needed):
import { scan } from 'a11yhawk';
const report = await scan('https://example.com');
console.log(report.structured.overallScore, report.structured.issues.length);Full AI analysis:
import { scan } from 'a11yhawk';
const report = await scan('https://example.com', {
llm: { apiKey: process.env.OPENROUTER_API_KEY },
wcagVersion: '2.2',
wcagLevel: 'AA',
onProgress: (e) => console.error(`[${e.stage}] ${e.message}`),
});Write the reports:
import { writeFileSync } from 'node:fs';
import { renderHtmlReport, scan } from 'a11yhawk';
const report = await scan('https://example.com', { llm: { apiKey: process.env.OPENROUTER_API_KEY } });
writeFileSync('report.json', JSON.stringify(report.structured, null, 2));
writeFileSync('report.md', report.markdown);
writeFileSync('report.html', renderHtmlReport(report)); // self-contained, opens from file://
if (report.annotatedScreenshot) writeFileSync('annotated.jpg', report.annotatedScreenshot);Scanning many pages? Hold an engine so the browser stays warm across scans:
import { A11yHawkEngine } from 'a11yhawk';
const engine = new A11yHawkEngine();
try {
for (const url of urls) {
const report = await engine.scan(url, { llm: { apiKey } });
// persist report however you like
}
} finally {
await engine.close();
}API
scan(url, options?) / engine.scan(url, options?)
One-shot scan() accepts ScanOptions & EngineOptions and manages the browser lifecycle for you. A11yHawkEngine takes EngineOptions in its constructor and ScanOptions per scan; it also exposes setConcurrency(n) (max concurrent page analyses, 1-10) and close() (shuts the browser down; the engine relaunches it on the next scan).
ScanOptions
| Option | Type | Default | Notes |
| ----------------------- | ---------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| llm | ScanLlmOptions | omitted | Omit entirely for Lighthouse-only mode |
| llm.apiKey | string | required in LLM mode | Key for the configured provider |
| llm.provider | 'openrouter' \| 'anthropic' | 'openrouter' | 'anthropic' calls the Claude API directly; see Anthropic provider |
| llm.model | string | anthropic/claude-sonnet-5 | Any model id your endpoint accepts. Anthropic default: claude-opus-5-5 |
| llm.baseUrl | string | https://openrouter.ai/api/v1 | Any OpenAI-compatible endpoint (Anthropic default: https://api.anthropic.com). Must be a public address unless allowPrivateNetworks is set |
| llm.generationParams | GenerationParams | engine defaults | maxTokens; temperature, topP, frequencyPenalty (OpenRouter only); effort (Anthropic only) |
| llm.pricing | Record<string, ModelPricing> | none | Anthropic only: USD per million tokens by model id, for usage.cost |
| llm.refusalFallback | boolean | true | Anthropic only: re-run a declined request on Anthropic's recommended fallback model |
| llm.debug | boolean | false | Verbose prompt/response logging |
| wcagVersion | '2.0' \| '2.1' \| '2.2' | '2.1' | |
| wcagLevel | 'A' \| 'AA' \| 'AAA' | 'AA' | |
| headers | ScanHeader[] | none | Custom request headers (cookies, auth): headers go only to the scan URL's origin, cookies to its host; see Security |
| lighthouse | boolean \| ScanLighthouseOptions | true | false skips the audit (LLM mode only); an object selects categories, see below |
| lighthouse.categories | LighthouseCategory[] | ['accessibility'] | 'accessibility' \| 'performance'; must include 'accessibility'; one run, one page load |
| lighthouse.includeRaw | boolean | false | Attach the trimmed raw Lighthouse result as report.lighthouse.raw |
| screenshot | boolean | true | false skips screenshot capture entirely (Lighthouse-only mode; see Bulk scanning) |
| annotate | boolean | true | Draw severity-colored boxes on a copy of the screenshot |
| onProgress | (e: ScanProgressEvent) => void | none | Stages: validating, capturing, auditing, analyzing, processing, annotating, complete, failed |
| logger | Logger | console logger | Bring your own structured logger |
EngineOptions
| Option | Type | Default | Notes |
| ------------------------ | --------- | -------------- | ------------------------------------------------------- |
| allowPrivateNetworks | boolean | false | See Security before enabling |
| browser.disableSandbox | boolean | false | Chromium --no-sandbox, needed on some container hosts |
| browser.debug | boolean | false | Extra capture diagnostics |
| logger | Logger | console logger | |
ScanReport
| Field | Type | Notes |
| --------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| structured | StructuredScanOutput | Machine-readable source of truth: score, statistics, WCAG coverage, issues with remediation, passed checks |
| markdown | string | Human-readable report |
| screenshot | Buffer \| null | Full-page JPEG |
| annotatedScreenshot | Buffer \| null | Issues boxed on the page, when annotation resolved any selectors |
| lighthouse | LighthouseTransformedResult \| null | Lighthouse findings mapped to WCAG, plus lighthouseVersion and (when requested) performance / raw |
| usage | ScanUsage \| null | Tokens + cost in USD, the provider, and (Anthropic) cache writes and the model that answered; null in Lighthouse-only mode |
| finalUrl | string | Guard-validated post-redirect URL that was actually analyzed |
| durationMs | number | |
Use renderHtmlReport(report) to turn any ScanReport into a single self-contained HTML document (inline CSS/JS, images as data URIs, renders from file://, itself WCAG AA accessible).
Anthropic provider
Set llm.provider: 'anthropic' to call the Claude API directly with an Anthropic API key instead of going through OpenRouter:
const report = await scan('https://example.com', {
llm: {
provider: 'anthropic',
apiKey: process.env.ANTHROPIC_API_KEY,
generationParams: { effort: 'high' },
pricing: { 'claude-opus-5-5': { inputPer1M: 4, outputPer1M: 20 } },
},
});- Defaults. The model is
claude-opus-5-5(DEFAULT_ANTHROPIC_MODEL) andbaseUrlishttps://api.anthropic.com, checked by the same endpoint guard as OpenRouter's. - Request. The response is streamed, with
maxTokensdefaulting to 64000.effortis sent asoutput_config.effortonly when you set it; otherwise the model's own default applies (mediumon Claude Opus 5.5).temperature,topP, andfrequencyPenaltyare not sent, because current Claude models reject non-default sampling values. The system prompt is the same for every scan and is sent as a cached block, so scans that run within the cache lifetime read it from the cache. - Refusal fallback. Claude's safety classifiers can decline a request. On models that support it (Claude Fable 5.1 and 5, Opus 5.5 and 5, Sonnet 5.5, and Mythos 5.1; not Haiku), the engine opts into Anthropic's server-side fallback, which re-runs a declined request on the model Anthropic recommends for the refusal category.
usage.servedModelIdthen names the model that answered. SetrefusalFallback: falseto turn it off. A refusal that stands fails withllm-refused. - Cost. The Claude API reports tokens, not dollars, so
usage.costis estimated fromllm.pricing: USD per million tokens, keyed by model id. Cache reads default to 0.1x the input price and cache writes to 1.25x. After a fallback, every attempt is priced at its own model's rates, so list the fallback models too (such asclaude-opus-5andclaude-opus-4-8); if any model that ran has no entry,costis 0. The estimate ignores long-context price tiers, such as Claude Haiku 5.5's higher rates above 100K input tokens, and it prices a declined attempt even when Anthropic does not bill that refusal category. - Screenshots. Tiles are cut 1932 px tall at the 1920 px viewport width, so Claude's high-resolution models read them without downscaling and one request can carry more than 20 of them. A page that needs more than 100 tiles fails with a non-retryable
llm-failedbefore any request is sent. - Retries and timeouts. The SDK retries an attempt that fails before its response starts streaming (connection errors, 408, 409, 429, 5xx) up to 2 times, and waits up to 5 minutes for a response to start. A stream that sends nothing for 5 minutes is cut. A failure partway through a stream is not retried. Unlike OpenRouter's, a 429 is a retryable
llm-rate-limit. - No environment. The client is configured only from your options.
ANTHROPIC_API_KEY,ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_CUSTOM_HEADERS, profiles, and the other variables the SDK reads by default are ignored.
Lighthouse performance category
Add 'performance' to lighthouse.categories to collect performance metrics from the same Lighthouse run and page load as the accessibility audit; no second pass, no extra navigation. Works in Lighthouse-only mode (no API key) and in LLM mode alike:
const engine = new A11yHawkEngine();
const report = await engine.scan(url, {
lighthouse: { categories: ['accessibility', 'performance'] },
screenshot: false, // optional: skip capture in bulk Lighthouse-only scans
});
report.lighthouse.performance.score; // 0-100 (or null if uncomputable)
report.lighthouse.performance.metrics; // FCP/LCP/TBT/Speed Index (ms), CLS
report.lighthouse.performance.opportunities; // top improvements, largest first
report.lighthouse.lighthouseVersion; // provenance, e.g. "13.4.0"report.lighthouse.performance is a LighthousePerformanceResult:
| Field | Type | Notes |
| ---------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| score | number \| null | Category score 0-100; null when Lighthouse could not compute one (metrics may remain) |
| metrics.firstContentfulPaintMs | number? | FCP, milliseconds |
| metrics.largestContentfulPaintMs | number? | LCP, milliseconds |
| metrics.cumulativeLayoutShift | number? | CLS, unitless, 3 decimals |
| metrics.totalBlockingTimeMs | number? | TBT, milliseconds |
| metrics.speedIndexMs | number? | Speed Index, milliseconds |
| opportunities[] | array | Failing audits with an estimate: auditId, title, estimatedSavingsMs?, estimatedSavingsBytes?; sorted by time savings, capped at 10 |
Notes:
'accessibility'is always required incategories: it is the analysis source for the structured report. Omitting it throwsScanErrorinvalid-options.- A failed run surfaces through the existing
lighthouse-failedcode (fatal in Lighthouse-only mode, non-blocking in LLM mode); requesting performance adds no new error shapes. - Performance runs are serialized engine-wide, regardless of
setConcurrency(n): parallel traces on one machine contend for CPU and skew FCP/LCP/TBT. Other scan stages still run concurrently, so for the strictest metric fidelity run performance scans at concurrency 1 on an otherwise idle machine. - Performance runs get a 60s Lighthouse timeout (accessibility-only runs keep 30s).
lighthouse: { includeRaw: true }attaches the raw Lighthouse result asreport.lighthouse.rawfor audits the engine does not map, trimmed of its largest dead weight (full-page screenshot artifact, screenshot audits, localization tables).- The structured report (
report.structured) stays accessibility-only; performance data lives onreport.lighthouseand is not persisted intoStructuredScanOutput.
Bulk scanning
For crawling many pages, hold one A11yHawkEngine for the whole run instead of calling the one-shot scan() per page, and disable outputs you will not use:
const engine = new A11yHawkEngine();
try {
for (const url of urls) {
const report = await engine.scan(url, {
screenshot: false, // no capture, no tiling, no annotation, no Buffers in the report
lighthouse: { categories: ['accessibility', 'performance'] },
});
await persist(report);
}
} finally {
await engine.close();
}screenshot: falseskips screenshot capture entirely;report.screenshotandreport.annotatedScreenshotarenulland memory stays flat across thousands of sequential scans. It requires Lighthouse-only mode (the LLM analysis needs the screenshot), so combining it withllmthrowsinvalid-options.- Chromium shuts down whenever no scan is in flight and relaunches on the next one, returning its native memory to the OS between sequential scans by design. Overlapping scans (
setConcurrency(n)> 1 with concurrent callers) share one warm browser. - Every scan-time failure is a
ScanErrorwith aretryableflag; mapretryable: trueonto your crawler's retry queue and drop the rest. - The repo carries an opt-in soak test asserting flat memory and stable handle counts across sequential scans on one engine:
A11YHAWK_SOAK=1 A11YHAWK_SOAK_SCANS=1000 NODE_OPTIONS=--expose-gc npx vitest run src/engine/scan.soak.test.ts.
Error handling
Every pipeline failure throws a ScanError with a code and a retryable flag, so queue-based hosts can map failures onto their retry semantics:
| Code | Retryable | Meaning |
| ------------------- | ---------------------------- | --------------------------------------------------------------------------------------------- |
| invalid-options | no | Contradictory configuration (e.g. no LLM and Lighthouse disabled) |
| invalid-url | no | URL failed validation (malformed, non-http(s), or blocked target) |
| blocked-request | no | The SSRF guard blocked a navigation or redirect |
| capture-failed | yes | Browser navigation/capture failure (often transient) |
| lighthouse-failed | yes | Lighthouse failed in Lighthouse-only mode (non-blocking in LLM mode) |
| llm-auth | no | API key invalid or expired, or (Anthropic) not allowed to make the request |
| llm-billing | no | Anthropic account out of credit or over its spend limit |
| llm-rate-limit | no; yes for Anthropic | Endpoint rate limit hit |
| llm-refused | no | The model declined to analyze the page (Anthropic), after any fallback |
| llm-failed | yes; no for an Anthropic 4xx | Other LLM call failure, including an unknown model or too many screenshot tiles for Anthropic |
| llm-malformed | no | Model response was not parseable as the expected JSON, or was cut off at maxTokens |
Decide retries from error.retryable rather than from the code: for the Anthropic provider, llm-rate-limit is retryable and llm-failed from a 4xx is not.
import { ScanError, scan } from 'a11yhawk';
try {
await scan(url, options);
} catch (error) {
if (error instanceof ScanError && error.retryable) requeue(url);
else reportPermanentFailure(error);
}CLI
Run it without installing anything:
# Free Lighthouse-only scan; reports written to ./a11yhawk-output/<timestamp>/
npx a11yhawk https://example.com
# Full AI analysis (bring your own OpenRouter-compatible key)
npx a11yhawk https://example.com --api-key sk-...
# Or call the Claude API directly with an Anthropic key
npx a11yhawk https://example.com --provider anthropic --api-key sk-ant-...
# One-time Chromium download if you have never used Playwright on this machine.
# The version pin matters here: outside a project, an unpinned `npx playwright`
# resolves the registry's latest, whose browser builds a11yhawk cannot use.
# `npx a11yhawk doctor` prints the exact command when Chromium is missing.
npx [email protected] install chromiumThe default command scans a single URL and writes report.json and report.md (add html for the self-contained HTML report), plus screenshot.jpg and annotated.jpg when available. LLM mode turns on automatically when an API key is present (flag or env) and --no-llm is absent; otherwise the scan runs Lighthouse-only, which needs no key and finishes in seconds.
Commands
| Command | Description |
| -------------------------- | ----------------------------------------------------- |
| a11yhawk <url> [options] | Scan a URL (default command) |
| a11yhawk serve [options] | Run the HTTP server (see Server mode) |
| a11yhawk doctor | Verify Chromium, Lighthouse, and key configuration |
| a11yhawk --help | Show usage |
| a11yhawk --version | Print the version |
Scan options
| Flag | Default | Description |
| ------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| --provider <name> | openrouter | openrouter (any OpenAI-compatible endpoint) or anthropic (env: A11YHAWK_PROVIDER) |
| --model <id> | anthropic/claude-sonnet-5 | LLM model id; claude-opus-5-5 by default with --provider anthropic (env: A11YHAWK_MODEL) |
| --api-key <key> | none | API key for the provider; presence enables LLM mode (env: A11YHAWK_API_KEY) |
| --base-url <url> | https://openrouter.ai/api/v1 | Provider endpoint (https://api.anthropic.com for anthropic); a local one needs --allow-private (env: A11YHAWK_BASE_URL) |
| --no-llm | off | Force Lighthouse-only mode even if a key is set |
| --wcag <2.0\|2.1\|2.2> | 2.1 | WCAG version |
| --level <A\|AA\|AAA> | AA | Conformance level |
| --header "Name: value" | none | Custom request header; repeatable |
| --format <list> | json,md | Comma-separated output formats: json, md, html |
| --output <dir> | ./a11yhawk-output/<timestamp> | Directory for report files and screenshots |
| --stdout | off | Print structured JSON to stdout instead of writing files |
| --open | off | Open report.html after writing (implies html in --format) |
| --fail-below <score> | none | Exit 1 when the overall score is below <score> (CI gate) |
| --allow-private | off | Permit private/internal scan targets and LLM endpoints (env: A11YHAWK_ALLOW_PRIVATE) |
| --no-annotate | off | Skip screenshot annotation |
| --no-lighthouse | off | Skip the Lighthouse audit (LLM mode only) |
| --quiet | off | Errors only |
| --verbose | off | Debug logging |
Progress is streamed to stderr as [stage] message; report data goes to stdout only under --stdout, so a11yhawk <url> --stdout > report.json pipes cleanly. --stdout writes no files, so it takes precedence over --open.
Environment variables
The CLI reads these as fallbacks; an explicit flag always wins. The library itself never reads the environment.
| Variable | Equivalent flag | Notes |
| -------------------------- | ----------------- | --------------------------------------------------------------------------- |
| A11YHAWK_API_KEY | --api-key | Enables LLM mode when set |
| A11YHAWK_PROVIDER | --provider | openrouter or anthropic; checked only in LLM mode |
| A11YHAWK_MODEL | --model | LLM model id |
| A11YHAWK_BASE_URL | --base-url | Provider endpoint |
| A11YHAWK_ALLOW_PRIVATE | --allow-private | Set to true to permit private/internal targets |
| A11YHAWK_DISABLE_SANDBOX | (none) | Set to true to launch Chromium with --no-sandbox (some container hosts) |
Exit codes
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------- |
| 0 | Success |
| 1 | Scan succeeded but the score was below --fail-below |
| 2 | Scan error (a ScanError; its [code] message is printed to stderr) |
| 3 | Configuration error (bad flag, contradictory options, or a doctor blocker) |
doctor
a11yhawk doctor verifies that a scan can run at all: Node.js >= 20, a usable Playwright Chromium (it prints the version-pinned npx playwright@<version> install chromium command matching its bundled Playwright when that is what is missing), and a resolvable Lighthouse CLI. It also reports whether an API key is configured and which provider A11YHAWK_PROVIDER selects, both informational only since Lighthouse-only mode needs neither. It exits 0 when Lighthouse-only scanning is possible and 3 otherwise, so it doubles as a CI preflight.
CI example
# .github/workflows/a11y.yml
name: Accessibility
on: [push]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npx [email protected] install --with-deps chromium
- name: Accessibility gate
run: npx a11yhawk https://your-site.example --fail-below 90 --format json,html --output a11y-report
- if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-report
path: a11y-report/Add --api-key ${{ secrets.OPENROUTER_API_KEY }} for full AI analysis. The job fails when the score drops below 90 and always uploads the report as a CI artifact.
Server mode
a11yhawk serve runs a small HTTP service over the same engine, with one warm browser shared across scans. Jobs live in memory: there is no database, and a restart drops every job and report. Persistence is the host's concern by design.
Running it
# From an install, or a clone after `npm run build`
a11yhawk serve # listens on :4000
# Docker (browser + system deps baked in; the recommended path).
# Images are published to GHCR with each tagged release.
docker run --rm -p 4000:4000 ghcr.io/charlesjones-dev/a11yhawk:latest
# Docker Compose (see examples/docker-compose.yml)
docker compose -f examples/docker-compose.yml upEndpoints
| Method and path | Description |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST /scans | Enqueue a scan. Body { "url": "...", "options": { ... } }. Returns 202 { id }, 400 for invalid options (including an llm.baseUrl on a private network), or 503 while 100 scans are already waiting. |
| GET /scans/:id | Job status, plus the report once completed or an error once failed. |
| GET /scans/:id/report.html | The rendered HTML report. 404 until the scan completes. |
| GET /healthz | Liveness, uptime, job counts, and the queue limit. Never requires auth. |
Enqueue a scan and poll it to completion:
# Lighthouse-only scan (no API key needed)
id=$(curl -s localhost:4000/scans \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}' | jq -r .id)
# Poll until .status is "completed" or "failed"
curl -s localhost:4000/scans/$id | jq '{status, score: .report.structured.overallScore}'
# Save the HTML report once it is ready
curl -s localhost:4000/scans/$id/report.html -o report.htmlThe options object accepts only llm { apiKey, provider, model, baseUrl, generationParams }, wcagVersion, wcagLevel, headers, lighthouse (boolean or { categories, includeRaw }, same semantics as the library option), screenshot, and annotate; any other field is ignored, including the library-only llm.pricing and llm.refusalFallback. In the JSON report, screenshots come back as base64 data: URIs rather than raw bytes, and a submitted llm.apiKey is never echoed back in any response.
Configuration
Every setting is an environment variable, read only by the server layer (never the library):
| Variable | Default | Purpose |
| --------------------------- | ------- | ------------------------------------------------------------------------------------ |
| A11YHAWK_PORT (or PORT) | 4000 | Listen port. serve --port <n> overrides both. |
| A11YHAWK_AUTH_TOKEN | (unset) | When set, every endpoint except /healthz requires Authorization: Bearer <token>. |
| A11YHAWK_CONCURRENCY | 2 | Max concurrent scans (clamped 1-10). Extra scans queue FIFO, up to 100 waiting. |
| A11YHAWK_JOB_TTL_SECONDS | 3600 | How long a finished job is retained before eviction. |
| A11YHAWK_ALLOW_PRIVATE | false | Permit scanning private/internal targets (see the note below). |
| A11YHAWK_DISABLE_SANDBOX | false | Launch Chromium with --no-sandbox; some container hosts require it. |
Server security note
allowPrivateNetworks is a server-level posture, set only by A11YHAWK_ALLOW_PRIVATE. A request body can never turn it on: that field, along with browser and logger, is stripped from submitted options. Leave it off anywhere scan URLs come from people you do not trust, and put network-layer egress rules around the server when it is multi-tenant. The same posture governs a submitted llm.baseUrl: with it off, an endpoint on a private network is rejected with 400, and every connection to the endpoint is checked again when the scan runs. The provider's error responses are logged, never returned to clients; a failed job reports only the HTTP status. The HTML report is served with a Content-Security-Policy that lets only its own script run. A client gets 30 seconds to send its request. /healthz stays open even when a token is set, so load balancers can probe it; it shows uptime and how many scans are queued and running, so keep it off the public internet if that matters to you. See the Security section below for the residual egress caveats that apply to every mode.
Security
A11yHawk is designed to be safe to embed in services where scan URLs come from untrusted users, and honest about its residual risks.
- SSRF request guard, default-on. Every request the scanned page makes is validated at the browser context level: scheme checks, per-request DNS resolution with no cached allow-verdicts, redirect-hop detection, service workers blocked, private, loopback, link-local, and reserved targets refused (including IPv6 addresses that embed one, such as NAT64 and 6to4 forms). A second guard intercepts every request in the shared browser, including the page Lighthouse opens for its audit, so the scanned page's scripts cannot reach a private address during the audit either, and a redirect to one is refused before it is sent. Scan URLs are re-validated at scan time (not just submission time) to narrow DNS-rebinding windows.
allowPrivateNetworks: trueexists because scanning your own internal apps is a primary self-hosting use case. It only widens which resolved addresses the guard accepts; every other protection stays active. Leave itfalseanywhere scan URLs come from people you don't trust.- LLM endpoint guard.
llm.baseUrlgets the same SSRF posture as scan targets: unlessallowPrivateNetworksis set, an endpoint on a private, loopback, or link-local address is refused before the scan starts, and the LLM client refuses any connection (including a redirect hop or a re-resolved hostname) that lands on one. Both providers get this check: the OpenRouter client through an HTTP agent, the Anthropic client through a fetch built on the same agent, which also never follows a redirect. A local endpoint such as Ollama needsallowPrivateNetworks: true(CLI:--allow-private). - Known residual risks: WebSocket connections are not intercepted, and Chromium resolves a hostname again after the guard has checked it, which leaves a short DNS-rebinding window. Network-layer egress filtering is the only complete mitigation. If you run A11yHawk multi-tenant, put egress rules around it.
- The browser's DevTools port is unauthenticated. Chromium listens for the DevTools protocol on a random loopback port so Lighthouse can reuse the browser, and Chromium offers no way to require credentials there. Any process on the same host, or any container sharing its network namespace (a Kubernetes sidecar, for example), can connect, drive the browser, read the pages and headers of other scans, and switch off the egress guard. Web pages cannot use it: Chromium refuses DevTools connections from web origins and checks the
Hostheader. Run A11yHawk in its own container or on a host without untrusted local users; the Docker image gets its own network namespace unless you run it with--network host. - Custom headers stay with the target. Header and bearer-token entries in
headersare added only to requests whose origin (scheme, host, and port) matches the scan URL, so third-party scripts, CDNs, and analytics on the page never receive them. Subdomains and thewww/bare variant are different origins, so pass the URL the page finally lands on. Cookie entries (and aCookieheader entry) are set as host-only cookies for the scan URL and follow normal cookie rules, which ignore the port: the browser sends them to that host on any port, for paths under the scan URL's directory, and over plainhttpas well when the scan URL ishttp. Residual gap: Playwright re-sends a request's headers on its redirect hops, so a same-origin URL that redirects to another origin still delivers them to the redirect target. - Subprocess hygiene: the Lighthouse CLI is spawned with
shell: falseand argv arrays; the attacker-controllable URL is never interpreted by a shell. The child gets onlyPATH,HOME,USERPROFILE, the temp-directory variables,SystemRoot,LANG,LC_ALL, andCHROME_PATH, never the rest of your environment, so keys such asA11YHAWK_API_KEYstay out of the Lighthouse dependency tree. - Key handling: API keys arrive as options, are never logged, and error messages from the LLM layer are sanitized so keys cannot leak through error chains. The Anthropic client reads no
ANTHROPIC_*environment variable, so the key, endpoint, and headers it sends are only the ones you pass. An HTTP error from the endpoint is reported by status only; the response body goes to the log. The built-in and CLI loggers also mask key-shaped strings, URL credentials (https://user:pass@...), and token-like query values (token,key,sig,code, and similar). A customloggerreceives values unmasked. - Model output is untrusted. A scanned page can try to steer the model through its content, so the engine treats the response as data: the score is recomputed from the criteria the model checked (0 when it reports none), statistics are recounted from the issues,
structured.urlis the URL you scanned, and severities, levels, and other fields are coerced to their allowed values before anything renders them. - Page content is marked as data. Everything the prompt takes from the page (HTML, accessibility tree, URL, and Lighthouse data) sits between boundary markers, the system prompt tells the model to treat marked content and text in the screenshots as data, never instructions, and page content is escaped so it cannot fake a marker. That narrows prompt injection but cannot rule it out: a page can still try to talk the model out of findings. For pages you don't control, check LLM-mode results against
report.lighthouse, which comes from deterministic audits the model cannot change. - Error messages name no internal addresses. A scan URL refused because its hostname resolves to a private address fails without naming the address, which goes to the log only, so a server client cannot use refusals to map internal DNS names.
- Report output: everything interpolated into the HTML report, including provider token counts, is entity-escaped, so a malicious scanned page or provider cannot inject markup or script into the report. Server mode also serves it with a
Content-Security-Policythat allows only the report's own inline script. - No telemetry. Nothing is collected, nothing phones home. Lighthouse's own error reporting is switched off on every run, whatever an earlier interactive
lighthouserun on the machine saved.
Found a vulnerability? Please open a GitHub security advisory rather than a public issue.
For AI agents
Everything above applies; this section is deliberately compact and exact.
- Package:
a11yhawk(npm). ESM only. Node >= 20. Requires Chromium:npx [email protected] install chromium(pin matches the bundled Playwright; unpinned is fine only inside a project that has a11yhawk installed). - Exports:
scan,A11yHawkEngine,renderHtmlReport,ScanError,createLogger,DEFAULT_MODEL,DEFAULT_ANTHROPIC_MODEL, plus all types (ScanOptions,EngineOptions,ScanReport,StructuredScanOutput,AccessibilityIssue, ...). Full.d.tsshipped. - Minimal complete program:
import { scan } from 'a11yhawk';
const report = await scan('https://example.com'); // Lighthouse-only, no key needed
// report.structured.overallScore: number 0-100
// report.structured.issues: Array<{ severity: 'critical'|'high'|'medium'|'low', wcagCriteria: string, title: string, remediation: string, ... }>- LLM mode: add
{ llm: { apiKey } }; key is an OpenRouter key unlessllm.baseUrlpoints elsewhere. For an Anthropic key, addprovider: 'anthropic'(calls the Claude API directly; default modelclaude-opus-5-5; setllm.pricingto get a cost). Expect 1-5+ minutes and provider token costs per scan in LLM mode; Lighthouse-only takes seconds and is free. - Scanning localhost or private hosts, or using a local
llm.baseUrl(Ollama, LM Studio), requires{ allowPrivateNetworks: true }. - All failures are
ScanErrorwith.code(see table above) and.retryable. Decide retries from.retryable, not.code: a non-retryable error will fail identically on retry, so do not loop on it. - CI gating pattern: run a scan, compare
report.structured.overallScoreto your threshold, exit non-zero below it. Or use the CLI's built-in gate:npx a11yhawk <url> --fail-below 80. - Shelling out instead of importing:
npx a11yhawk <url> --stdoutprints the structured JSON to stdout (progress goes to stderr). Exit codes: 0 success, 1 below--fail-below, 2 scan error, 3 configuration error.npx a11yhawk doctorpreflights the environment. - A self-hostable HTTP mode exists (
a11yhawk serve,POST /scansthen pollGET /scans/:id); see Server mode above.
Examples
Runnable scripts live in examples/. They import a11yhawk by package name (Node resolves the package's own name from inside the repo), so every script is byte-for-byte what you would write in your own project.
From a clone:
npm install
npx playwright install chromium # one-time browser download
npm run build # examples resolve 'a11yhawk' to the local dist/ buildThen:
# Self-contained demo: serves a deliberately broken page locally, scans it,
# writes an HTML report to examples/output/. No API key, no external network.
node examples/local-fixture.mjs
# Free Lighthouse-only scan of any public URL, summary to stdout
node examples/lighthouse-only.mjs https://your-site.example
# Full AI analysis: writes report.json / report.md / report.html + screenshots
# to examples/output/. Takes minutes and costs tokens on your key.
OPENROUTER_API_KEY=sk-... node examples/full-scan.mjs https://your-site.example
# CI gate: exit 0 if score >= threshold, 1 below it, 2 on scan error
node examples/ci-gate.mjs https://your-site.example 80full-scan.mjs also honors A11YHAWK_MODEL to override the default model. If you copy a script into your own project, replace the build step with npm install a11yhawk; nothing else changes.
The directory also holds two deployment templates: docker-compose.yml (server mode as a container) and github-action.yml (a copy-paste CI job gating on --fail-below).
Output formats
structured(JSON): overall score (0-100: the percent of checked WCAG criteria with no issues found, recomputed by the engine and 0 when the model reports no criteria; the Lighthouse accessibility score in Lighthouse-only mode), per-severity statistics, WCAG coverage marking each checked criterion as issues found or no issues found (passed), issues with location/selector, code context, impact, and remediation, passed checks. Stable shape; treat as the source of truth.markdown: the same content as a readable report.- HTML (via
renderHtmlReport): a single dark-theme file with score ring, severity breakdown, sortable/collapsible issues with client-side resolve tracking (localStorage), and the annotated screenshot. Attach it to CI artifacts, tickets, or email; it has zero external dependencies.
Reports describe what was checked and what was found on one page. Automated and AI checks find WCAG failures but cover only part of WCAG, so a scan cannot establish conformance, and the reports say so: they are not a compliance certification.
Roadmap
- GitHub Action wrapper.
- SARIF and JUnit output formats.
Contributing
Contributions welcome. Setup:
git clone https://github.com/charlesjones-dev/a11yhawk
cd a11yhawk
npm install
npx playwright install chromium
npm run verify # lint + typecheck + tests + build; must pass before a PR| Script | What it does |
| ------------------ | -------------------------------------------- |
| npm run verify | The full gate: lint, typecheck, tests, build |
| npm test | Vitest in watch mode |
| npm run test:run | Tests once |
| npm run format | Prettier write |
Conventions: TypeScript strict (including noUncheckedIndexedAccess), ESM only, tests colocated as *.test.ts next to the module they cover. The request guard, URL validator, and Lighthouse spawn are security-sensitive; changes there get extra scrutiny and must not weaken defaults. Please keep PRs focused and include tests for behavior changes.
Relationship to AccessHawk
A11yHawk is the open-source engine behind AccessHawk, extracted and maintained by the same author. The hosted product adds scheduling, history, dashboards, teams, and a managed API around this engine; the engine itself - capture, audits, AI analysis, and reports - is all here, and running it on your own infrastructure is the point.
License
Apache-2.0. Copyright Charles Jones.
