Firebase Hosting Headers Config: Cache, Security, CORS

The headers block in firebase.json controls cache TTL, security policy, and CORS. Here is the minimum config that ships fast and safe — and the patterns that cost you.

Firebase Hosting serves your static files with a default set of HTTP headers that are fine until they are not. The headers block in firebase.json overrides that default — for caching, for security, and for CORS. Get this right once and you stop debugging stale CSS, lost AdSense placement, and cross-origin font fetches forever.

Background

Every HTTP response carries headers that tell the browser what to do with the body — how long to cache it, whether to allow it in an iframe, which scripts can run, whether other domains can fetch the asset. Firebase Hosting sets sensible defaults but cannot guess your specific intent. The headers array in firebase.json lets you set per-path overrides. The trap is that one over-eager pattern silently breaks every page on the site.

How to tell

  • You deploy a CSS fix and users still see the old version for hours.
  • Lighthouse complains about “Serve static assets with an efficient cache policy.”
  • Your site is empty when embedded in an iframe, or a partner cannot load it.
  • A custom font hosted on your domain fails to load on a partner site with a CORS error.
  • AdSense or analytics scripts get blocked by your Content Security Policy.

Quick verdict

For long-lived hashed assets (/_astro/*.js, /assets/*.[hash].css), set Cache-Control: public, max-age=31536000, immutable. For HTML files, set Cache-Control: public, max-age=0, must-revalidate. Add security headers (Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy) once and forget about them. Add CORS only on the specific assets that need it.

Cache headers that actually work

The pattern that breaks sites: a single rule applying max-age=31536000 to everything. Fingerprinted JS / CSS is fine to cache for a year. HTML must not be — otherwise users see the old page until their browser cache expires.

{
  "hosting": {
    "headers": [
      {
        "source": "**/*.@(js|css|woff2|jpg|jpeg|png|webp|avif|svg)",
        "headers": [
          { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
        ]
      },
      {
        "source": "**/*.html",
        "headers": [
          { "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }
        ]
      },
      {
        "source": "/sitemap.xml",
        "headers": [
          { "key": "Cache-Control", "value": "public, max-age=3600" }
        ]
      }
    ]
  }
}

immutable tells the browser never to revalidate — only safe for hashed filenames. For HTML, max-age=0, must-revalidate lets the browser keep the file but forces a conditional request on every load, so users always see the latest deploy.

Security headers, set once

Five headers cover 95% of common attacks. Add them in firebase.json and forget about them.

{
  "hosting": {
    "headers": [
      {
        "source": "**",
        "headers": [
          { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
          { "key": "X-Content-Type-Options",   "value": "nosniff" },
          { "key": "X-Frame-Options",          "value": "SAMEORIGIN" },
          { "key": "Referrer-Policy",          "value": "strict-origin-when-cross-origin" },
          { "key": "Permissions-Policy",       "value": "camera=(), microphone=(), geolocation=()" }
        ]
      }
    ]
  }
}

Skip Content-Security-Policy on the first pass — get it wrong and every script on your site breaks. Add it later with Content-Security-Policy-Report-Only first to see what would have been blocked.

CORS only where needed

The mistake: copying Access-Control-Allow-Origin: * to all assets “for safety.” That actually opens your APIs and JSON endpoints to any site. CORS is only needed for assets that will be fetched by JavaScript on a different origin — typically fonts served to partner sites, or a JSON endpoint consumed by a separate domain.

{
  "source": "**/*.@(woff|woff2)",
  "headers": [
    { "key": "Access-Control-Allow-Origin", "value": "*" }
  ]
}

For a JSON endpoint consumed by a specific known origin, list it explicitly instead of *:

{
  "source": "/api/public/**",
  "headers": [
    { "key": "Access-Control-Allow-Origin", "value": "https://partner.example.com" },
    { "key": "Vary", "value": "Origin" }
  ]
}

Common mistakes

  • A single ** rule with max-age=31536000. HTML caches for a year, users never see new deploys until they manually clear cache.
  • Adding X-Frame-Options: DENY and then wondering why your AdSense Auto Ads break — AdSense iframes are same-origin, so SAMEORIGIN is the safe default.
  • Setting Content-Security-Policy without report-only first. The first deploy with a strict policy will break analytics, fonts, and any third-party script.
  • Forgetting that headers rules are evaluated top-to-bottom — a broad ** rule above a specific rule silently overrides nothing, but a specific rule above the broad one applies. Order matters.
  • Using Access-Control-Allow-Origin: * alongside Access-Control-Allow-Credentials: true. Browsers reject the combination. Either remove credentials or list specific origins.
  • Editing firebase.json and forgetting that firebase serve does not always honor the headers — test on a real deploy.

FAQ

  • How do I verify a header is actually being served?: curl -sI https://yoursite.com/path | head -30 shows the response headers. Or DevTools Network tab, click the asset, look at Response Headers.
  • Why is my new Cache-Control not taking effect?: Firebase CDN caches the old headers for up to 5 minutes after deploy. Wait, or force-refresh via ?v=test to confirm the new headers are at the origin.
  • Should I cache /index.html for an hour?: Only if you can live with users seeing stale content for an hour after each deploy. For an active site, 0 with revalidation is safer.
  • Do these headers apply to redirects?: No. Redirects emit their own headers. The headers block applies to URLs that return content (200 or 304), not to 301/302 responses.
  • How do I set headers on a Cloud Function response, not the Hosting layer?: Headers set inside the function body (res.set('Cache-Control', '...')) work for that response. Hosting headers apply to static files only.

Tags: #Indie dev #Firebase #Hosting #headers #cache