
Low-Cost 3-Tier Application on Firebase — Secure by Default with API Key Restrictions, App Check & Security Rules | 2026
A secure, low-cost 3-tier application on Firebase means Hosting for the frontend, callable Cloud Functions for the logic tier, and Firestore plus Cloud Storage for data — hardened with four controls: restrict the Firebase API key to your domain and only the needed APIs, enforce App Check on every backend, lock Firestore and Storage behind least-privilege security rules, and configure CORS correctly on functions and buckets. On the Blaze plan's free tiers, this stack runs at $0–5/month for low-traffic apps.
By Mateusz Chmielewski · Aug 4, 2026 · 20 min read
What Is a 3-Tier Application on Firebase?
A 3-tier application separates presentation, logic, and data. On Firebase, the presentation tier is Firebase Hosting serving your static SPA over a global CDN; the logic tier is Cloud Functions for Firebase (2nd gen, running on Cloud Run) exposing callable or HTTPS endpoints; the data tier is Cloud Firestore for documents and Cloud Storage for files. Because the frontend talks to Google APIs directly, security cannot rely on a hidden server-side secret — it is enforced by App Check (is this my genuine app?), Firebase Auth (who is calling?), and Security Rules (what may they touch?).
Think of a self-service restaurant. The dining room (Hosting) is open to anyone. Your membership card (Firebase Auth) says who you are, the bouncer at the kitchen door (App Check) checks the card was issued by this restaurant chain and not photocopied, and the kitchen shelves (Firestore rules) only unlock the ingredients your membership tier allows. The restaurant's name sign — the API key — is public by design; stealing the sign alone gets nobody a meal.
| Concept | Explanation | When to use |
|---|---|---|
| Firebase API Key | A public identifier embedded in client code that routes requests to your project. | Always required in the frontend; restrict it to your domains and a minimal API list. |
| App Check | Attestation (reCAPTCHA Enterprise on web) proving requests come from your genuine app build. | Blocking bots, scripts, and replayed requests before they reach billable backends. |
| Security Rules | Declarative access control evaluated server-side on every Firestore/Storage request. | Your primary data-layer authorization — never trust client-side checks. |
| Callable Functions | Cloud Functions with built-in Auth/App Check context and automatic CORS handling. | Business logic that must run server-side with the caller's identity attached. |
| Blaze Plan | Pay-as-you-go plan required for Cloud Functions and App Check, with generous always-free quotas. | Any production Firebase app; set a budget alert so 'pay-as-you-go' never surprises you. |
Why Harden a Firebase App Instead of Shipping the Defaults?
Firebase's quick-start path leaves real gaps: the auto-created API key is unrestricted, so anyone can reuse it from curl or another website to burn your quotas; App Check ships unenforced, so bots can hammer your functions and Firestore directly; default Firestore rules in 'test mode' expire into open access or get widened to allow read, write: if true under deadline pressure; and ad-hoc CORS fixes like Access-Control-Allow-Origin: * on functions quietly disable browser protections for credentialed endpoints.
Layering four controls closes those gaps with near-zero cost: (1) domain + API restrictions on the API key stop casual key reuse, (2) enforced App Check rejects non-genuine clients before they hit billable resources, (3) least-privilege Firestore/Storage rules make authorization server-side and auditable, and (4) explicit CORS allowlists keep cross-origin access intentional. For workloads that outgrow client-direct access, the same discipline extends naturally to org-wide guardrails like the [GCP organization policy baseline](/tutorial/gcp-organization-policies-defaults-terraform-module) and to protecting server-side credentials with [Secret Manager automatic rotation](/tutorial/gcp-secret-manager-terraform-module-automatic-rotation).
| Feature | thisService | altA | altB |
|---|---|---|---|
| API Key Misuse | Key restricted to your domains + APIs; App Check verifies the app itself | Default unrestricted key usable from anywhere | Obfuscating the key in the bundle (trivially reversible) |
| Bot/Script Traffic | Rejected pre-billing by App Check attestation | Hits functions/Firestore directly, burns quota | Hand-rolled rate limiting per endpoint |
| Data Authorization | Server-side Security Rules per document/field | Client-side checks only (bypassed via REST API) | Single backend proxy for all reads (extra latency + cost) |
| Monthly Cost (low traffic) | $0–5 on Blaze free tiers | Unbounded abuse from leaked key / open rules | Always-on VM or GKE cluster: $30+ before traffic |
Prerequisites
- Firebase project on the Blaze (pay-as-you-go) plan with a budget alert configured
- Firebase CLI v13+ installed and authenticated (`firebase login`)
- gcloud CLI v450.0+ for API key and quota management
- Node.js 20+ for the functions tier
- A custom domain verified in Firebase Hosting (for HTTP referrer restrictions)
- Terraform CLI v1.5.0+ (optional — for managing the API key restrictions as code)
Step-by-Step Guide
Step 1: Scaffold the 3-Tier Project
Initializing Firebase Hosting, Functions, Firestore, and Storage in one workspace with emulators for local development. A clean scaffold separates the tiers from day one and gives you local emulators, so security rules and App Check logic are testable before anything touches production.
npm install -g firebase-tools
firebase login
mkdir three-tier-app && cd three-tier-app
firebase init hosting functions firestore storage emulators
# Choose: existing project, JavaScript (or TypeScript) for functions,
# single-page-app hosting from ./public, all emulators enabled.
firebase use --add # alias your project
firebase emulators:start
Step 2: Restrict the Firebase API Key to Your Domain and APIs
Finding the auto-created browser key and applying HTTP referrer restrictions for your exact domains plus an allowlist of only the APIs the app uses. The API key ships inside your public JavaScript bundle by design. Without restrictions, anyone can copy it and call your project's APIs from any origin — burning your quotas and polluting your analytics. Domain restriction makes the key useless outside your site.
# As code (preferred) — google_apikeys_key mirrors the key Firebase created.
# For an existing key, import it: terraform import google_apikeys_key.web \
# projects/PROJECT_NUMBER/locations/global/keys/KEY_ID
resource "google_apikeys_key" "web" {
project = var.project_id
name = "firebase-web-key"
display_name = "Firebase web key (domain-restricted)"
restrictions {
# Only callable from your real origins
browser_key_restrictions {
allowed_referrers = [
"https://app.example.com/*",
"https://app.example.com",
"https://three-tier-app.web.app/*",
"https://three-tier-app.web.app",
"https://three-tier-app.firebaseapp.com/*",
"https://three-tier-app.firebaseapp.com",
]
}
# Only the APIs this 3-tier app actually calls
api_targets {
service = "identitytoolkit.googleapis.com" # Firebase Auth
}
api_targets {
service = "tokenviewer.googleapis.com" # Auth token exchange
}
api_targets {
service = "firestore.googleapis.com"
}
api_targets {
service = "firebaserules.googleapis.com" # Storage rules checks
}
api_targets {
service = "firebaseinstallations.googleapis.com" # App Check / FID
}
api_targets {
service = "recaptchaenterprise.googleapis.com" # App Check attestation
}
}
}
Step 3: Enforce App Check with reCAPTCHA Enterprise
Registering the web app for App Check, initializing it in the frontend before any other Firebase SDK call, and flipping enforcement on for Functions, Firestore, and Storage. App Check is the control that actually proves a request came from your genuine app build, not a script replaying your API key. Until enforcement is ON, attestation is advisory only — bots sail straight through.
// src/firebase.js — initialize App Check FIRST, before auth/firestore/storage
import { initializeApp } from "firebase/app";
import {
initializeAppCheck,
ReCaptchaEnterpriseProvider,
} from "firebase/app-check";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
import { getStorage } from "firebase/storage";
import { getFunctions } from "firebase/functions";
export const app = initializeApp(firebaseConfig);
if (import.meta.env.DEV) {
// Debug token printed once in the console — register it in the
# Firebase console (App Check → Apps → Manage debug tokens)
self.FIREBASE_APPCHECK_DEBUG_TOKEN = true;
}
export const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider(
import.meta.env.VITE_RECAPTCHA_SITE_KEY
),
isTokenAutoRefreshEnabled: true,
});
export const auth = getAuth(app);
export const db = getFirestore(app);
export const storage = getStorage(app);
export const functions = getFunctions(app, "europe-west1");
Step 4: Build the Logic Tier: Callable Function with Auth, App Check & Explicit CORS
A 2nd-gen callable Cloud Function that demands both a verified App Check token and an authenticated user, with CORS narrowed to your real origins for any plain HTTPS endpoints. Callable functions get App Check and Auth context for free, and their CORS is handled by the SDK — the dangerous mistakes happen on plain onRequest functions where developers slap cors({origin: true}) on everything. Narrow origins explicitly.
const { onCall, onRequest, HttpsError } = require("firebase-functions/v2/https");
const cors = require("cors");
const ALLOWED_ORIGINS = [
"https://app.example.com",
"https://three-tier-app.web.app",
"https://three-tier-app.firebaseapp.com",
];
const corsMiddleware = cors({ origin: ALLOWED_ORIGINS });
// Preferred: callable function — SDK handles CORS, injects auth + App Check.
exports.createOrder = onCall(
{ enforceAppCheck: true, region: "europe-west1" },
(request) => {
if (!request.auth) {
throw new HttpsError("unauthenticated", "Sign in first.");
}
// request.app is set only when App Check verification passed.
const { itemId, quantity } = request.data;
// ... validate + write to Firestore with the Admin SDK ...
return { orderId: "ord_123", itemId, quantity };
}
);
// If you must expose a plain HTTPS endpoint (webhooks, health checks):
exports.publicStatus = onRequest(
{ region: "europe-west1" },
(req, res) => {
corsMiddleware(req, res, () => {
res.json({ status: "ok" });
});
}
);
Step 5: Lock Down Firestore with Least-Privilege Security Rules
Replacing the default rules with per-collection authorization: users read/write only their own documents, role claims gate admin data, and field-level validation rejects malformed writes. Security Rules are the ONLY thing standing between the public internet and your data — the Firestore REST endpoint is reachable by anyone with your (public) project config. Client-side filtering is not access control.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
function isOwner(uid) {
return signedIn() && request.auth.uid == uid;
}
function isAdmin() {
return signedIn()
&& request.auth.token.get("admin", false) == true;
}
// Users can only ever touch their own profile document.
match /users/{uid} {
allow read, update: if isOwner(uid)
&& !request.resource.data.diff(resource.data)
.affectedKeys().hasAny(["role", "plan"]);
allow create: if isOwner(uid)
&& request.resource.data.keys().hasOnly(
["displayName", "createdAt", "role", "plan"])
&& request.resource.data.role == "user";
allow delete: if false;
}
// Orders: create as yourself, read your own, never update totals.
match /orders/{orderId} {
allow create: if signedIn()
&& request.resource.data.uid == request.auth.uid
&& request.resource.data.quantity is int
&& request.resource.data.quantity > 0
&& request.resource.data.quantity <= 100;
allow read: if isAdmin()
|| (signedIn() && resource.data.uid == request.auth.uid);
allow update, delete: if false; // state changes via functions only
}
// Deny everything else by default.
match /{document=**} {
allow read, write: if false;
}
}
}
Step 6: Secure Cloud Storage: Rules + Bucket CORS
Restricting uploads by owner, content type, and size in storage.rules, and setting an explicit CORS policy on the bucket so browser uploads work only from your origins. Storage is the classic abuse vector: without rules, anyone can upload gigabytes to your bill or host malware on your domain. Without a bucket CORS policy, legitimate browser PUT uploads fail — tempting developers to 'fix' it by disabling security instead.
// storage.rules
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
function signedIn() { return request.auth != null; }
// Avatars: only the owner, images only, max 2 MB.
match /avatars/{uid}/{fileName} {
allow read: if true; // public profile pictures
allow write: if signedIn() && request.auth.uid == uid
&& request.resource.size < 2 * 1024 * 1024
&& request.resource.contentType.matches('image/(png|jpeg|webp)');
}
// Everything else: deny.
match /{allPaths=**} {
allow read, write: if false;
}
}
}
Step 7: Apply Bucket CORS, Add Hosting Security Headers, and Deploy
Setting the Storage bucket CORS policy, adding defense-in-depth response headers in firebase.json, and shipping all tiers together. This is where the configuration becomes real: bucket CORS unblocks browser uploads from your domain only, and Hosting headers (X-Content-Type-Options, Referrer-Policy, a tight CSP) blunt XSS that could otherwise bypass every rule above by running as the user.
# cors.json — Storage bucket CORS, your origins only
[
{
"origin": [
"https://app.example.com",
"https://three-tier-app.web.app",
"https://three-tier-app.firebaseapp.com"
],
"method": ["GET", "PUT"],
"responseHeader": ["Content-Type", "x-goog-resumable"],
"maxAgeSeconds": 3600
}
]
Verification & Health Check
Best Practices
- Treat the API Key as Public — Restrict It Anyway
- Enforce App Check Before You Need It
- Deny by Default in Security Rules
- Never Use CORS Wildcards on Authenticated Endpoints
- Set a Budget Alert on the Blaze Plan
Common Mistakes
- {"errorCode":"APPCHECK_401_UNAUTHENTICATED","symptoms":"After enabling enforcement, legitimate users get 401 errors from Firestore or callable functions.","rootCause":"App Check was initialized after other SDK calls, the reCAPTCHA site key belongs to a different domain, or the CI/preview environment has no registered debug token.","fixCommand":"Check the Network tab for the X-Firebase-AppCheck header; register debug tokens in the Firebase console under App Check → Apps → Manage debug tokens","code":"// Wrong order — auth() captures a client before App Check exists:\nconst auth = getAuth(app);\ninitializeAppCheck(app, { provider: ... });\n\n// Right — App Check first:\nconst appCheck = initializeAppCheck(app, {\n provider: new ReCaptchaEnterpriseProvider(SITE_KEY),\n isTokenAutoRefreshEnabled: true,\n});\nconst auth = getAuth(app);\n","language":"javascript","filename":"src/firebase.js","prevention":"Keep one firebase.js module that always initializes App Check first, and import SDK instances only from that module."}
- {"errorCode":"API_KEY_HTTP_REFERRER_BLOCKED","symptoms":"403 with `Requests from referer https://... are blocked` for your own site after adding restrictions.","rootCause":"The allowed_referrers list misses an origin variant — typically the bare domain without /*, a www subdomain, or the *.firebaseapp.com alias.","fixCommand":"gcloud services api-keys list --project=PROJECT_ID --format=json | jq '.[].restrictions.browserKeyRestrictions'","code":"allowed_referrers = [\n \"https://app.example.com\", # bare origin (some browsers send no path)\n \"https://app.example.com/*\",\n \"https://www.app.example.com/*\",\n \"https://PROJECT.web.app/*\",\n \"https://PROJECT.firebaseapp.com/*\",\n]\n","language":"hcl","filename":"api-key.tf","prevention":"Allowlist every Hosting alias and test from a fresh incognito session after each restriction change."}
- {"errorCode":"FIRESTORE_PERMISSION_DENIED","symptoms":"Reads fail with `Missing or insufficient permissions` even though the user is signed in.","rootCause":"The query is not satisfiable by the rules — e.g. listing /orders without where('uid','==',uid). Rules are not filters; they only validate that every returned document is allowed.","fixCommand":"Run the query in the Firestore emulator rules playground to see which clause the rules reject","code":"// Rules: allow read: if resource.data.uid == request.auth.uid;\n\n// Fails — could return other users' orders:\nconst q = query(collection(db, \"orders\"));\n\n// Passes — provably scoped to the caller:\nconst q = query(\n collection(db, \"orders\"),\n where(\"uid\", \"==\", auth.currentUser.uid)\n);\n","language":"javascript","filename":"src/orders.js","prevention":"Write rules and queries as a pair, and cover both with @firebase/rules-unit-testing in CI."}
- {"errorCode":"CORS_PREFLIGHT_FAILED","symptoms":"Browser console shows `No 'Access-Control-Allow-Origin' header` on file uploads to Storage.","rootCause":"The Cloud Storage bucket has no CORS configuration — Storage security rules and bucket CORS are separate layers, and rules do not add CORS headers.","fixCommand":"gsutil cors set cors.json gs://PROJECT_ID.appspot.com","code":"[\n {\n \"origin\": [\"https://app.example.com\"],\n \"method\": [\"GET\", \"PUT\"],\n \"responseHeader\": [\"Content-Type\", \"x-goog-resumable\"],\n \"maxAgeSeconds\": 3600\n }\n]\n","language":"json","filename":"cors.json","prevention":"Store cors.json in the repo next to storage.rules and apply both in the deploy pipeline so environments never drift."}
- {"errorCode":"BILLING_QUOTA_EXHAUSTED","symptoms":"Functions stop responding near month-end; console shows quota exhausted on the free tier.","rootCause":"A leaked unrestricted API key or unenforced App Check let bots hammer billable endpoints until free-tier quotas drained.","fixCommand":"Check App Check metrics (verified vs unverified requests) and Cloud Logging for traffic patterns; enforce App Check and restrict keys, then raise quotas deliberately if traffic is genuine","code":"resource \"google_billing_budget\" \"firebase_cap\" {\n billing_account = var.billing_account_id\n display_name = \"firebase-cap\"\n amount {\n specified_amount {\n currency_code = \"USD\"\n units = \"10\"\n }\n }\n threshold_rules { threshold_percent = 0.5 }\n threshold_rules { threshold_percent = 0.9 }\n}\n","language":"hcl","filename":"budget.tf","prevention":"Defense in depth: restricted keys + enforced App Check stop abuse upstream, and a budget alert catches anything that slips through."}
Cost Analysis
| Feature | metric | cost1k | cost10k | cost100k | cost1m |
|---|---|---|---|---|---|
| Firebase Hosting (10 GB storage, 360 MB/day egress free) | $0.00 | $0.00 | ~$1.80 | ~$18.00 | |
| Cloud Functions (2M invocations + compute free tier) | $0.00 | $0.00 | $0.00 | ~$0.40 | |
| Firestore (50K reads / 20K writes per day free) | $0.00 | $0.00 | ~$0.36 | ~$10.80 | |
| App Check / reCAPTCHA Enterprise (1M assessments/mo free) | $0.00 | $0.00 | $0.00 | ~$1.00 | |
| Cloud Storage (5 GB + 1 GB/day egress free) | $0.00 | $0.00 | ~$0.50 | ~$5.00 |