DEV Community

Norbert Takács
Norbert Takács

Posted on

Building a WCAG Accessibility Checker API with Puppeteer and axe-core

The EU Accessibility Act (EAA) took effect in June 2025, requiring digital products and services to meet WCAG 2.1 AA standards. This means millions of websites now need compliance checking.

I built an API that does this in one call — send a URL, get back a structured WCAG compliance report with a 0-100 score, violation details, and fix suggestions.

The Architecture

Client → API Gateway → Lambda (arm64) → Puppeteer + axe-core
                            ↕
                        DynamoDB (cache, usage tracking)
Enter fullscreen mode Exit fullscreen mode

Stack: TypeScript, Puppeteer, axe-core, AWS Lambda, DynamoDB, Terraform

How It Works

  1. Receive a URL via POST request
  2. Validate the URL and check for SSRF (block private IPs)
  3. Check robots.txt (respect site owners)
  4. Launch headless Chromium, navigate to the page
  5. Inject axe-core and run accessibility analysis
  6. Calculate a weighted score and return structured results

The Hard Parts

Chromium on Lambda

Lambda has a 250MB deployment limit, and Chromium is ~62MB compressed. I used @sparticuz/chromium as a Lambda layer, uploaded via S3 since it exceeds the 50MB direct upload limit.

The browser pool pattern is essential — reuse the browser across invocations, but create a fresh page per request:

let browser: Browser | null = null;

async function getBrowser(): Promise<Browser> {
  if (browser?.isConnected()) return browser;
  browser = await puppeteer.launch({
    args: chromium.args,
    executablePath: await chromium.executablePath(),
    headless: chromium.headless,
  });
  return browser;
}
Enter fullscreen mode Exit fullscreen mode

SSRF Prevention

Since the API accepts arbitrary URLs, SSRF prevention is critical. I resolve DNS before connecting and block all private IP ranges:

  • 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • 169.254.169.254 (AWS metadata endpoint)
  • IPv6 loopback and link-local addresses

axe-core Gotcha

The biggest bug I hit: axe.run('document') treats the string 'document' as a CSS selector, not the document object. You need to pass the actual globalThis['document'] object inside page.evaluate().

Scoring Formula

score = max(0, 100 - critical*10 - serious*5 - moderate*2 - minor*0.5)
Enter fullscreen mode Exit fullscreen mode

Automated tools catch approximately 30-57% of all accessibility issues, so this score is an approximation — manual review is still recommended.

Example Response

{
  "score": 72,
  "summary": {
    "critical": 2,
    "serious": 5,
    "moderate": 8,
    "minor": 3
  },
  "violations": [
    {
      "id": "color-contrast",
      "impact": "serious",
      "wcagCriteria": ["1.4.3"],
      "nodes": [{
        "selector": ".subtitle",
        "fix": "Change text color to at least #767676"
      }]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Cost at Scale

Monthly Scans AWS Cost
1,000 ~$1
10,000 ~$9
50,000 ~$40

Running on arm64 (Graviton2) saves ~20% compared to x86.

Try It

The API is available on RapidAPI with a free tier (50 scans/month). If you're working on accessibility tooling or need to integrate WCAG checking into your CI/CD pipeline, give it a try.


Have questions about the implementation? Drop a comment below.

Top comments (0)