If you build a frontend that calls Firebase Functions from a different origin, CORS stops being “that browser thing” and turns into a real deployment concern fast.

Firebase gives you a few ways to deal with CORS, and they’re not equal. Some are clean and low-friction. Some are easy to misuse. Some look secure until you add credentials and realize you just shipped a broken policy.

Here’s the practical comparison guide I wish more people had before copy-pasting Access-Control-Allow-Origin: * into every function.

The short version

For Firebase Functions, you’ll usually choose between:

  1. Built-in CORS config on callable HTTP handlers
  2. Manual header handling
  3. Express + cors middleware
  4. Dynamic allowlists for multi-origin apps

My opinion:

  • For simple public APIs, built-in or manual CORS is fine.
  • For apps with cookies, auth, staging domains, and multiple frontends, use an explicit allowlist.
  • For anything non-trivial in Node.js, Express + cors middleware is usually the least painful.
  • Never treat * as a harmless default unless the endpoint is truly public and does not use credentials.

What CORS actually changes in Firebase Functions

Firebase Functions are just HTTP endpoints behind Google infrastructure. The browser decides whether frontend JavaScript can read the response.

That means your function can return 200 OK, and your browser app still fails because the response is missing the right headers.

Typical CORS headers you’ll deal with:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: ETag, X-RateLimit-Remaining

A real-world example: api.github.com returns:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That’s a good reminder that CORS is not just about allowing origins. Sometimes the thing your frontend really needs is access to a response header like ETag or rate-limit metadata.

Option 1: Built-in CORS in Firebase Functions v2

Firebase Functions v2 supports CORS configuration directly for HTTP functions. This is the cleanest path when your needs are basic.

Node.js example

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

exports.hello = onRequest(
  {
    cors: [/example\.com$/, "https://app.example.com"],
  },
  (req, res) => {
    res.json({ ok: true });
  }
);

Python example

from firebase_functions import https_fn

@https_fn.on_request(cors=["https://app.example.com"])
def hello(req: https_fn.Request) -> https_fn.Response:
    return https_fn.Response('{"ok": true}', mimetype="application/json")

Pros

  • Low boilerplate
  • Less chance of forgetting preflight handling
  • Good default for straightforward APIs
  • Keeps CORS policy close to function definition

Cons

  • Can feel limited once you need nuanced behavior
  • Less flexible for conditional headers, custom exposed headers, or per-route behavior
  • Teams often assume “built-in” means “safe by default,” which is not true if the origin list is sloppy

If your app has one production frontend and maybe one staging frontend, this approach is usually enough.

Option 2: Manual CORS headers

This is the most flexible option and also the easiest to mess up.

Node.js example

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

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://staging.example.com",
]);

exports.api = onRequest((req, res) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.set("Access-Control-Allow-Origin", origin);
    res.set("Vary", "Origin");
    res.set("Access-Control-Allow-Credentials", "true");
    res.set("Access-Control-Expose-Headers", "ETag, X-RateLimit-Remaining");
  }

  if (req.method === "OPTIONS") {
    res.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
    res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
    return res.status(204).send("");
  }

  res.json({ message: "CORS handled manually" });
});

Pros

  • Maximum control
  • Easy to add Access-Control-Expose-Headers
  • Good for unusual requirements like route-specific policies
  • No dependency on middleware behavior you didn’t read

Cons

  • Very easy to forget OPTIONS
  • Very easy to forget Vary: Origin
  • Very easy to accidentally allow credentials with a bad origin policy
  • Header logic gets duplicated across functions unless you abstract it

I use manual handling when I need exact behavior and want zero magic. But I only trust it if the team is disciplined enough to centralize the logic.

Option 3: Express with cors middleware

If you already use Express in Firebase Functions, this is often the most maintainable route.

Node.js example

const express = require("express");
const cors = require("cors");
const { onRequest } = require("firebase-functions/v2/https");

const app = express();

const allowedOrigins = [
  "https://app.example.com",
  "https://staging.example.com",
];

app.use(cors({
  origin(origin, callback) {
    if (!origin) return callback(null, true); // server-to-server or curl
    if (allowedOrigins.includes(origin)) return callback(null, true);
    return callback(new Error("Not allowed by CORS"));
  },
  credentials: true,
  exposedHeaders: ["ETag", "X-RateLimit-Remaining"],
}));

app.get("/profile", (req, res) => {
  res.json({ user: "alice" });
});

exports.api = onRequest(app);

Pros

  • Cleaner for multi-route APIs
  • Middleware keeps policy in one place
  • Handles preflight without repetitive code
  • Easier to evolve over time

Cons

  • Another dependency
  • Misconfigurations can be hidden in middleware options
  • Developers sometimes enable permissive defaults and forget about them
  • Error behavior for blocked origins may need tuning

For anything larger than one or two routes, this is usually my preferred Node setup.

Option 4: Dynamic allowlists

This is the real-world pattern for SaaS apps, white-label setups, preview deployments, and multiple environments.

You validate the Origin header against a trusted list from config, env vars, or a database.

Example

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

function getAllowedOrigins() {
  return (
    process.env.ALLOWED_ORIGINS || ""
  ).split(",").map(s => s.trim()).filter(Boolean);
}

exports.dynamicCors = onRequest((req, res) => {
  const origin = req.headers.origin;
  const allowedOrigins = getAllowedOrigins();

  if (origin && allowedOrigins.includes(origin)) {
    res.set("Access-Control-Allow-Origin", origin);
    res.set("Vary", "Origin");
    res.set("Access-Control-Allow-Credentials", "true");
  }

  if (req.method === "OPTIONS") {
    res.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
    res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
    return res.status(204).send("");
  }

  res.json({ ok: true });
});

Pros

  • Works well across dev/staging/prod
  • Safer than hardcoding *
  • Fits modern deployment workflows
  • Good for multi-tenant setups if done carefully

Cons

  • More moving parts
  • Easy to accidentally trust bad input if origin data is user-controlled
  • Database-backed allowlists can introduce latency or failure modes
  • Regex-heavy policies get confusing fast

My rule: if an origin can make authenticated browser requests, it should be there because you explicitly trust it, not because your regex happened to match.

The wildcard trap

Access-Control-Allow-Origin: * is fine for public, unauthenticated resources.

It is not fine when you need credentials.

If you set:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

the browser will reject it. Credentials require a specific origin, not *.

That catches a lot of Firebase developers because they start with a public endpoint, then later add cookie auth or authenticated fetches and wonder why the browser breaks.

Don’t forget exposed headers

A lot of CORS guides stop at origin and methods. That’s incomplete.

If your frontend needs to read non-simple response headers, you need Access-Control-Expose-Headers.

For example:

res.set(
  "Access-Control-Expose-Headers",
  "ETag, Link, Retry-After, X-RateLimit-Remaining"
);

That pattern shows up in real APIs. GitHub exposes headers like:

  • ETag
  • Link
  • Location
  • Retry-After
  • X-RateLimit-Remaining

If your frontend needs pagination, caching, or rate-limit info, expose those headers deliberately.

Common Firebase CORS mistakes

I keep seeing the same bugs:

1. Forgetting preflight requests

If the browser sends OPTIONS and your function returns a normal 404 or 500, your app fails before the real request is sent.

2. Missing Vary: Origin

If you return different Access-Control-Allow-Origin values per request, add:

Vary: Origin

Without it, caches can serve the wrong CORS response.

3. Reflecting every origin blindly

This is the classic fake-security pattern:

res.set("Access-Control-Allow-Origin", req.headers.origin);

If you do that without validation, you effectively allow every origin.

4. Using CORS as auth

CORS is a browser access control feature. It does not protect your endpoint from curl, scripts, or server-side requests. Use real auth.

5. Ignoring other security headers

CORS is only one piece. If you’re hardening browser-facing responses more broadly, look at CSP and related headers too. If you need a practical reference for those, CSP Guide is useful.

What I’d choose

Here’s the opinionated version:

  • Single frontend, simple API: use Firebase’s built-in CORS option.
  • Express app with several routes: use cors middleware with an explicit allowlist.
  • Authenticated browser app: never use *; return the exact allowed origin.
  • Frontend needs pagination or caching headers: explicitly set Access-Control-Expose-Headers.
  • Multi-environment setup: use a config-driven allowlist, not hardcoded one-off logic.

If you want the least surprising setup, this is a solid baseline for Firebase Functions in Node:

const express = require("express");
const cors = require("cors");
const { onRequest } = require("firebase-functions/v2/https");

const app = express();

const allowedOrigins = [
  "https://app.example.com",
  "https://staging.example.com",
];

app.use(cors({
  origin(origin, callback) {
    if (!origin) return callback(null, true);
    if (allowedOrigins.includes(origin)) return callback(null, true);
    return callback(new Error("Blocked by CORS"));
  },
  credentials: true,
  exposedHeaders: ["ETag", "Link", "Retry-After", "X-RateLimit-Remaining"],
}));

app.post("/data", (req, res) => {
  res.set("ETag", '"abc123"');
  res.set("X-RateLimit-Remaining", "42");
  res.json({ ok: true });
});

exports.api = onRequest(app);

That setup is boring, explicit, and hard to misread. For CORS, boring is good.

For the official details on Firebase HTTP functions and supported CORS configuration, check the Firebase documentation: https://firebase.google.com/docs/functions/http-events