ScreenshotNeo

BlogEngineering

How to Avoid CORS Errors with Puppeteer in Firebase Callable Functions

Fix Firebase Puppeteer CORS errors by matching the trigger, client protocol, origin, and preflight behavior with working onCall and onRequest examples.

By the ScreenshotNeo team29 September 20269 min read

How to Avoid CORS Errors with Puppeteer in Firebase Callable Functions

Most Puppeteer CORS failures in Firebase come from mixing two separate concerns: the browser-to-Firebase request and the Puppeteer browser-to-target-site request. Fix the first by matching your Firebase trigger with the correct client protocol. Fix the second by remembering that Puppeteer runs on the server and cannot change the target site’s CORS headers.

Use Firebase’s httpsCallable client for an onCall function. Configure the callable cors option only when you need an origin allowlist. Use onRequest for a normal HTTP endpoint and configure CORS explicitly, because HTTP functions default to no CORS policy. Then debug the browser preflight before changing Chromium flags.

What the CORS error actually means

A browser error such as No 'Access-Control-Allow-Origin' header is present on the requested resource means the browser rejected the response from your Firebase endpoint. The browser checks the response headers; adding an Access-Control-Allow-Origin request header from JavaScript does not solve it and can create another preflight.

Separate the browser-to-Firebase preflight from the server-side Puppeteer navigation.
Separate the browser-to-Firebase preflight from the server-side Puppeteer navigation.

There can be two unrelated requests in this architecture:

  1. Web app to Firebase: your browser calls the callable or HTTP function. This is where Firebase CORS and preflight behavior matter.
  2. Firebase function to target website: Puppeteer launches Chromium, navigates to a page, and reads or captures it. This server-side navigation is not governed by the browser page’s CORS policy.

Puppeteer can set request headers and intercept requests, but it cannot make a target server emit Access-Control-Allow-Origin. See Firebase’s callable functions documentation, HTTP events documentation, and Puppeteer’s getting-started guide.

Choose onCall or onRequest first

Decision onCall onRequest
Client protocol Firebase client SDK, usually httpsCallable Ordinary HTTP with fetch, cURL, or another client
Default CORS Enabled for all origins by default Disabled by default
Authentication Callable protocol carries Firebase auth and App Check tokens when configured You validate headers, cookies, or tokens yourself
Preflight Expected when JSON or authorization headers are used Depends on your HTTP method and headers
Best fit A Firebase app calling a protected backend operation A public or cross-platform HTTP API

Firebase documents that callable functions accept a cors option as a boolean, string, regular expression, or array. The default is true for callable functions and false for other HTTP functions. An allowlist must match the browser’s exact scheme, hostname, and port: http://localhost:3000 and https://app.example.com are different origins.

Working onCall implementation with Puppeteer

Install the Firebase Functions SDK and Puppeteer in your functions directory:

npm install firebase-functions firebase-admin puppeteer

This v2 callable function restricts calls to one production origin, checks authentication, launches Puppeteer’s bundled browser, and closes Chromium even when navigation fails.

const { onCall, HttpsError } = require('firebase-functions/v2/https');
const puppeteer = require('puppeteer');

exports.scrape = onCall(
  { cors: ['https://app.example.com'] },
  async (request) => {
    if (!request.auth) {
      throw new HttpsError('unauthenticated', 'Sign-in required');
    }

    const browser = await puppeteer.launch({ headless: true });
    try {
      const page = await browser.newPage();
      await page.goto('https://example.com', {
        waitUntil: 'networkidle2',
        timeout: 30000,
      });

      return {
        ok: true,
        title: await page.title(),
        url: page.url(),
      };
    } catch (error) {
      throw new HttpsError('internal', 'Page capture failed');
    } finally {
      await browser.close();
    }
  },
);

Call it from a web client with the Firebase SDK instead of hand-writing a JSON request:

import { getFunctions, httpsCallable } from 'firebase/functions';

const functions = getFunctions(app, 'us-central1');
const scrape = httpsCallable(functions, 'scrape');
const result = await scrape({});
console.log(result.data);

The callable protocol wraps your payload in a data property and manages the expected token headers. A raw fetch call that omits that envelope can look like a CORS failure even though the real problem is an invalid callable request.

Working onRequest implementation

Choose onRequest when you need a conventional HTTP endpoint. Configure a narrow origin list and validate every input before starting Chromium.

const { onRequest } = require('firebase-functions/v2/https');
const puppeteer = require('puppeteer');

exports.renderPageHttp = onRequest(
  { cors: ['https://app.example.com'] },
  async (req, res) => {
    if (req.method !== 'POST') {
      res.status(405).json({ error: 'Use POST' });
      return;
    }

    const targetUrl = req.body?.url;
    if (typeof targetUrl !== 'string' || !targetUrl.startsWith('https://')) {
      res.status(400).json({ error: 'A valid HTTPS url is required' });
      return;
    }

    const browser = await puppeteer.launch({ headless: true });
    try {
      const page = await browser.newPage();
      await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 30000 });
      res.json({ ok: true, title: await page.title(), url: page.url() });
    } catch (error) {
      res.status(502).json({ error: 'Target page failed to load' });
    } finally {
      await browser.close();
    }
  },
);

For local development, temporarily allow your exact development origin:

const allowedOrigins = [
  'http://localhost:3000',
  'http://localhost:5173',
  'https://app.example.com',
];

exports.renderPageHttp = onRequest(
  { cors: allowedOrigins },
  async (req, res) => {
    res.json({ ok: true });
  },
);

Do not use cors: true for an authenticated production browser client unless every origin is intentionally allowed. Regular expressions are useful for controlled subdomains, but keep the pattern narrow.

Why callable requests send OPTIONS

A browser sends an OPTIONS preflight when the actual request is not CORS-safelisted. Callable requests commonly use application/json and an Authorization header, both of which can require preflight. Firebase’s callable protocol accounts for this when you use the SDK.

In browser developer tools, open the failed OPTIONS request and inspect:

  • Access-Control-Allow-Origin: must match the requesting origin exactly, or be a permitted wildcard where appropriate.
  • Access-Control-Allow-Methods: must include the method used by the actual request.
  • Access-Control-Allow-Headers: must include requested non-safelisted headers.
  • Status code and response body: a 401, 403, App Check failure, or callable error is an application response, not automatically a CORS defect.

Configuration details that prevent common failures

Match the deployed region

If the function is deployed in europe-west1 but the client initializes Functions in us-central1, the request can reach the wrong URL and appear to be a CORS problem. Set the client region to the deployed region and verify the deployment output.

Use the web app origin in the allowlist

The allowlist contains https://app.example.com, not the Firebase function URL. Include the scheme and port. A Firebase Hosting preview URL is a separate origin from your production Hosting domain.

Keep headers minimal

Every custom header can trigger a preflight. Let the Firebase SDK attach authentication and App Check headers. Do not add Access-Control-Allow-Origin to requests. If you use onRequest, only send headers your server actually needs.

Do not confuse Puppeteer headers with CORS response headers

await page.setExtraHTTPHeaders({
  'Accept-Language': 'en-US,en;q=0.9',
});

await page.setRequestInterception(true);
page.on('request', (request) => {
  if (request.resourceType() === 'image') {
    request.abort();
  } else {
    request.continue();
  }
});

These APIs control requests made by the server-side page. They do not rewrite the target server’s response or grant a browser permission to read a cross-origin response.

Step-by-step debugging checklist

  1. Identify the trigger. Check the source export and deployment output. Do not infer onCall or onRequest from the URL.
  2. Use the matching client. Call httpsCallable(functions, 'scrape') for onCall. Use ordinary HTTP for onRequest.
  3. Return a constant. Temporarily remove Puppeteer and return { ok: true }. This separates Firebase transport problems from browser problems.
  4. Inspect OPTIONS. Compare the request’s Origin with the response’s allow-origin, methods, and headers.
  5. Verify region and URL. Confirm the client region, deployed function name, and Firebase project.
  6. Check authentication separately. Read the status and response body for 401, 403, missing App Check, or HttpsError.
  7. Add Puppeteer gradually. Launch the browser, then add page.goto, then selectors and screenshots.
  8. Close resources. Keep browser.close() in a finally block so repeated invocations do not retain Chromium processes.

Troubleshooting common errors

Symptom Likely cause Fix
No allow-origin header on an HTTP function onRequest has its default disabled CORS policy Add cors: ['https://your-origin'] or handle CORS explicitly.
Callable returns 400 after a manual fetch The request is missing the callable envelope or uses unsupported headers Use httpsCallable, or reproduce Firebase’s callable protocol exactly.
OPTIONS is rejected Origin, method, or requested headers are not allowed Inspect preflight headers and add the exact origin and required headers.
Works on localhost but not in production Production scheme, hostname, or port is absent from the allowlist Add the deployed web origin, including HTTPS.
401 or 403 shown as a CORS error Authentication, App Check, or authorization failed Inspect the actual response status and function logs before changing CORS.
Puppeteer navigation fails Target timeout, blocked bot check, DNS issue, or invalid URL Test the function without Puppeteer first, set a navigation timeout, and log the target status.
Chromium cannot start Runtime lacks compatible executable or required launch configuration Use Puppeteer’s bundled browser where possible; changing executablePath is a compatibility decision.
A screenshot service can handle consent overlays before capture.
A screenshot service can handle consent overlays before capture.

Performance, reliability, and security notes

Launching Chromium is expensive compared with returning JSON. Keep navigation timeouts bounded, avoid downloading unnecessary resources with request interception, and reuse a browser only when you can manage concurrent pages and cleanup safely. A fresh browser per invocation is simpler and gives stronger isolation, while a shared browser can reduce startup work at the cost of lifecycle complexity.

Set an explicit waitUntil policy. networkidle2 is useful for pages that finish loading asynchronously, but analytics and long polls can prevent an idle state. For those pages, wait for a meaningful selector or use a bounded delay after the initial load.

Never accept arbitrary URLs from an unauthenticated caller without controls. Validate schemes, restrict destinations where appropriate, and consider server-side request forgery risks when the function can reach private network addresses. Limit screenshot dimensions, request body sizes, and invocation time. Log function errors without recording credentials or sensitive page content.

Costs come from function invocations, compute time, network transfer, and any browser resources your deployment uses. A rejected preflight normally does not reach Puppeteer, but a request that passes CORS can still consume runtime before a target-page failure. Measure your own invocation duration and set limits that match your workload.

Or skip the browser setup

If your goal is a clean website screenshot rather than custom browser automation, ScreenshotNeo provides a single GET request. Its API accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and each response reports the result in X-Page-Verdict and X-Billed headers. An MCP server also lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.

See the ScreenshotNeo API documentation for all options. This is a runnable cURL example:

curl -G 'https://api.screenshotneo.com/v1/shot' \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python:

import requests

r = requests.get(
    'https://api.screenshotneo.com/v1/shot',
    params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'},
    timeout=90,
)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)

Node.js:

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
require('fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo includes full-page capture with lazy images, CSS element capture, dark mode, device presets, custom viewports, retina scale, PDF output, custom CSS and JavaScript, clicks, selector waits, delays, network-idle waits, blocked resources, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, TTL caching, signed image links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs, a usage API, and an OpenAPI specification. Parameter names used by other screenshot APIs also work, which can simplify migration.

The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan. Create a free ScreenshotNeo account and start with the 1,000 included screenshots.

FAQ

Does Puppeteer bypass CORS?

No. Puppeteer controls a browser in the function runtime. It does not change CORS headers emitted by Firebase or by the target website.

Should a screenshot function use onCall or onRequest?

Use onCall when a Firebase app is the client and you want the SDK’s callable protocol. Use onRequest when you need a conventional HTTP API for browsers, scripts, or external services.

Why does adding Access-Control-Allow-Origin to fetch make things worse?

That header belongs in the server response. Sending it from the browser is unnecessary and can cause another preflight.

Can I solve the problem with Chromium’s –disable-web-security flag?

That flag does not configure Firebase’s response headers and is not a substitute for a correct callable or HTTP API setup. Fix the endpoint protocol and preflight first.

Why is a failed target page different from a CORS failure?

A target-page failure occurs inside Puppeteer after Firebase has accepted the request. A CORS failure occurs when the browser refuses to expose the Firebase response to your web application. Test them independently.