Firebase Hosting Headers: Cache, Security, CORS Config

The headers block in firebase.json controls cache TTL, security policy, and CORS. Copy the minimum config that ships fast and safe, and skip the patterns that silently break your whole site.

Firebase Hosting serves your static files through Google’s CDN with a default Cache-Control of max-age=3600 (one hour) when you set nothing. That default is fine until it is not: you push a CSS fix, the CDN and every browser hold the old file for an hour, and your users complain. The headers block in firebase.json overrides the default for caching, security, and CORS. Configure it once and you stop debugging stale assets, broken AdSense placement, and cross-origin font errors for good.

TL;DR

  • Cache fingerprinted assets (/_astro/*.js, *.[hash].css) for a year with public, max-age=31536000, immutable; cache HTML with public, max-age=0, must-revalidate so every deploy is visible immediately.
  • Firebase’s default is max-age=3600 for static files and private (no CDN cache) for anything served by a Cloud Function or Cloud Run. Set the header yourself when the default does not fit.
  • Add five security headers once (Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy) and move on. Hold Content-Security-Policy for a second pass with report-only mode.
  • Add CORS only on the specific assets a different origin’s JavaScript fetches, never blanket *.
  • Header rules use the first matching source glob, top to bottom. Put specific rules above broad ones.

How Firebase headers behave (the part the docs bury)

Every HTTP response carries headers that tell the browser how long to cache the body, whether it can sit in an iframe, which scripts may run, and whether other domains can fetch the asset. Firebase Hosting sets defaults but cannot read your intent. The headers array in firebase.json overrides them per path. Three facts decide whether your config works (verified against Firebase’s Configure Hosting behavior and Manage cache behavior docs, as of June 2026):

  • Default cache is one hour. Static content with no Cache-Control gets max-age=3600. So a “stale for an hour” bug is the default, not a misconfiguration.
  • First matching rule wins. Like redirects and rewrites, Hosting applies the header rule whose source glob matches first, top to bottom. Order your array specific-to-broad.
  • Header rules run before rewrites. Headers are matched against the request URL path before any rewrite, so they target the URL the user requested, not the rewritten target.

The source glob is the recommended pattern syntax; regex (RE2) is also accepted if you need it.

Symptoms that point here

  • You deploy a CSS fix and users see the old version for up to an hour.
  • Lighthouse flags “Serve static assets with an efficient cache policy.”
  • Your site renders blank inside an iframe, or a partner cannot embed it.
  • A self-hosted font fails on a partner domain with a CORS error in the console.
  • AdSense or analytics scripts get blocked once you add a Content Security Policy.

Cache headers that actually work

The pattern that breaks sites is one rule applying max-age=31536000 to everything. Fingerprinted JS and CSS are safe to cache for a year because the filename changes on every build. HTML is not: cache it long and users keep 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, which is 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 land on the latest deploy.

Tuning the CDN separately from the browser

max-age controls the browser. s-maxage controls the shared CDN cache and overrides max-age there. If you want the browser to revalidate often but let Google’s edge hold the asset longer, set both:

{ "key": "Cache-Control", "value": "public, max-age=0, s-maxage=600, must-revalidate" }

This pattern matters most for dynamic responses from Cloud Functions or Cloud Run, which Firebase does not cache on the CDN at all by default (their default is private). You opt in by setting public plus an s-maxage.

Asset typeRecommended Cache-ControlWhy
Hashed JS/CSS/images (*.[hash].*)public, max-age=31536000, immutableFilename changes each build; safe to cache forever
HTML pagespublic, max-age=0, must-revalidateEvery deploy must be visible immediately
sitemap.xml, robots.txtpublic, max-age=3600Crawlers tolerate an hour of staleness
Cloud Function JSONpublic, max-age=0, s-maxage=600Edge caches 10 min; browsers always recheck
Firebase default (no header)max-age=3600One hour, applied if you set nothing

Security headers, set once

Five headers cover the bulk of common attacks. Add them under a ** source and forget 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 stops loading. When you do add it, ship Content-Security-Policy-Report-Only first, watch the violation reports in DevTools for a week, then promote it to the enforcing header. The MDN CSP reference lists every directive.

CORS only where it is needed

The common mistake is copying Access-Control-Allow-Origin: * onto every asset “for safety.” That opens any JSON endpoint or API to every site on the internet. CORS is only required for assets that a different origin’s JavaScript fetches: typically fonts you serve to partner sites, or a JSON endpoint consumed from another domain.

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

For a JSON endpoint used by one known origin, list it explicitly instead of *, and add Vary: Origin so caches do not serve the wrong CORS response:

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

Common mistakes

  • One ** rule with max-age=31536000. HTML caches for a year and users never see new deploys until they manually clear cache.
  • X-Frame-Options: DENY followed by confusion over why AdSense Auto Ads break. AdSense iframes are same-origin, so SAMEORIGIN is the safe default.
  • Shipping a strict Content-Security-Policy without report-only first. The first deploy will block analytics, fonts, and any third-party script.
  • Putting a broad ** rule above a specific one. First match wins, so the broad rule fires and the specific one never runs. Order specific-to-broad.
  • Combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. Browsers reject the pair. Either drop credentials or name explicit origins.
  • Trusting firebase serve to honor your headers. It does not always emulate them. Verify on a real deploy.

Verify the deploy

After firebase deploy --only hosting, confirm the header actually lands:

curl -sI https://yoursite.com/_astro/app.abcd1234.js | grep -i cache-control
# expect: cache-control: public, max-age=31536000, immutable

curl -sI https://yoursite.com/index.html | grep -i cache-control
# expect: cache-control: public, max-age=0, must-revalidate

If you still see the old value, the CDN holds the previous headers for a short window after deploy. Append a throwaway query string (?v=test) to bypass the cache and confirm the origin is serving the new header.

FAQ

  • How do I verify a header is actually being served?: Run curl -sI https://yoursite.com/path and read the response headers, or open DevTools, click the asset in the Network tab, and check Response Headers.
  • Why is my new Cache-Control not taking effect?: Firebase clears the CDN on redeploy, but the edge can serve the previous headers for a short window. Wait a few minutes or hit the URL with ?v=test to confirm the origin already serves the new header.
  • What is the default Cache-Control if I set nothing?: Static files get max-age=3600 (one hour). Anything served by a Cloud Function or Cloud Run gets private and is not cached on the CDN at all.
  • Should I cache /index.html for an hour?: Only if you can accept users seeing stale content for an hour after each deploy. For an active site, max-age=0, must-revalidate is safer.
  • Do these headers apply to redirects?: No. A 301/302 emits its own headers. The headers block applies to URLs that return content (200 or 304), not redirect responses.
  • How do I set headers on a Cloud Function response instead of the Hosting layer?: Call res.set('Cache-Control', '...') inside the function. Note that only the __session cookie passes through to backend code, and it becomes part of the CDN cache key.

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