firebase-multi-env
v1.5.0
Published
Multi-environment Firebase routing (Origin + allowedEnvs) with pinned per-env deploys and project-parity isolation
Maintainers
Readme
firebase-multi-env
Hardened single-project environment isolation for Firebase: Origin → environment → Firestore database, with claim-based authorization for gated environments and pinned per-env deploys as the production path.
One Firebase project, multiple Firestore databases, multiple Hosting sites. Production users do not need special rights; gated envs (qual/cert/…) require an allowedEnvs claim.
Production path: pinned mode + per-env service accounts + secrets + deploy isolation (project-parity). Separate Firebase projects remain the strongest blast-radius boundary for billing/Auth/admin — see Security model and
templates/PROJECT_PARITY.md.
Package layout
src/ # runtime, server, functions, client
eslint/ # no-bare-admin-firestore, require-pinned-runtime
templates/ # rules, IAM, secrets, deploy isolation, project parity
bin/ # grant-env, init, doctor [--strict], provisionPublic imports:
| Import | Purpose |
|---|---|
| firebase-multi-env/server | createEnvRuntime, createGetDb, createGetDbForEnv, guards |
| firebase-multi-env/functions-v1 | callable wrapper (v1) |
| firebase-multi-env/functions-v2 | callable wrapper (v2) |
| firebase-multi-env/http | onRequest / Express-style wrapper |
| firebase-multi-env/client | callable (+ prefix) + client Firestore kit |
| firebase-multi-env/eslint | ESLint plugin (forbid bare Admin Firestore / require pinned) |
Install
npm install firebase-multi-env
npm install firebase firebase-admin firebase-functionsLocal link:
npm run build && npm link
# in your app
npm link firebase-multi-envSecurity model
| Request from | Database | Needs allowedEnvs? |
|---|---|---|
| Public env Hosting origin (e.g. prod) | that env's DB | No |
| Gated env Hosting origin (e.g. qual/cert) | that env's DB | Yes |
| Localhost → cloud gated env | gated DB | Yes |
| Full local emulators | emulator default DB | No (optional) |
Client appEnv is only a hint on localhost. Hosted Origin always wins when recognized.
This is request routing plus pinned deploy isolation. Pair with IAM, secrets, and CI for project-parity. It is not a cryptographic Hosting lock.
Protects against: client appEnv overrides, accidental dynamic DB selection, ungated non-prod access, wrong Origin on a pinned runtime, unpinned Cloud deploys (startup assert), silent getDb() outside request context (when configured), leaked emulator env vars on pinned deploys, bare Admin Firestore (ESLint + doctor).
Does not automatically protect against: overly broad service accounts, shared secrets, bad IAM/CI, or shared-project Auth/billing blast radius — close those with the templates under multi-env/ after init.
Full matrix: SECURITY.md and templates/THREAT_MODEL.md.
Isolation (pinned) — required for production
| pinned | Deploy shape | What Origin does |
|---|---|---|
| false (default) | One process may serve many envs | Selects which DB (local/dev only) |
| true | One deploy (+ SA) per env | Confirms the pinned env |
Deployed Cloud Functions reject unpinned config at startup unless allowUnpinnedCloudDeploy: true.
export const appEnvRuntime = createEnvRuntime({
pinned: true,
pinnedEnvironment: process.env.APP_ENV, // "qual" for the qual deploy
environments: { /* ... */ },
onResolveEnv: (event) => logger.info('env_resolved', event), // optional audit
});Pinned defaults:
- Unknown / missing hosted Origin → reject (
rejectUnknownOrigin: true) getDb()/getRuntimeEnv()outside a request wrapper → throw- Referer fallback → off (
allowRefererFallback: false) - Emulator host env vars on a real deploy → refuse
- Runtime refuses to serve any env other than the pinned one
Project-parity stack (via init):
multi-env/PROJECT_PARITY.mdmulti-env/iam-sa-per-env.mdmulti-env/secrets-per-env.mdmulti-env/deploy-isolation.mdmulti-env/github-actions.deploy.example.yml- Storage + Firestore rules snippets
Scripts and background jobs should use an explicit DB accessor (never rely on silent defaults):
import { createGetDb, createGetDbForEnv } from 'firebase-multi-env/server';
export const getDb = createGetDb(appEnvRuntime);
export const getDbForEnv = createGetDbForEnv(appEnvRuntime);
// in a scheduled job / script:
const db = getDbForEnv('qual');Optional local hardening without pinning (not for Cloud deploys):
createEnvRuntime({
pinned: false, // local/emulator only — blocked on Cloud unless allowUnpinnedCloudDeploy
rejectUnknownOrigin: true,
requireRequestContext: true,
allowRefererFallback: false,
environments: { /* ... */ },
});Quick start scaffolding
npx firebase-multi-env init
npx firebase-multi-env provision --project my-app --envs production,qual
npx firebase-multi-env doctor --strictWrites:
firestore.rules.snippets/(Firestore + Storage, gated + public)MULTI_ENV_SETUP.mdmulti-env/— project parity, IAM, secrets, deploy isolation, pinned examples, CI workflowmulti-env/provision/— generated gcloud scripts (viaprovision)
ESLint guardrails
import multiEnv from 'firebase-multi-env/eslint';
export default [
{
plugins: { 'firebase-multi-env': multiEnv },
rules: {
'firebase-multi-env/no-bare-admin-firestore': 'error',
'firebase-multi-env/require-pinned-runtime': 'error',
},
},
];One-time Firebase setup
firebase firestore:databases:create qual-env --location nam5
firebase firestore:databases:create cert-env --location nam5
firebase hosting:sites:create myapp-qual
firebase hosting:sites:create myapp-cert
firebase target:apply hosting qual myapp-qual
firebase target:apply hosting cert myapp-cert
firebase target:apply hosting prod myappExample firebase.json (same Functions source, unique prefix per env so IDs do not collide):
{
"functions": [
{ "source": "functions", "codebase": "prod", "prefix": "prod", "configDir": "functions/config/prod" },
{ "source": "functions", "codebase": "qual", "prefix": "qual", "configDir": "functions/config/qual" },
{ "source": "functions", "codebase": "cert", "prefix": "cert", "configDir": "functions/config/cert" }
],
"firestore": [
{ "database": "(default)", "rules": "firestore.prod.rules", "indexes": "firestore.indexes.json" },
{ "database": "qual-env", "rules": "firestore.qual.rules", "indexes": "firestore.indexes.json" },
{ "database": "cert-env", "rules": "firestore.cert.rules", "indexes": "firestore.indexes.json" }
],
"hosting": [
{
"target": "qual",
"public": "dist",
"rewrites": [{ "source": "/api/**", "function": { "functionId": "qual-api", "codebase": "qual" } }]
},
{
"target": "cert",
"public": "dist",
"rewrites": [{ "source": "/api/**", "function": { "functionId": "cert-api", "codebase": "cert" } }]
},
{
"target": "prod",
"public": "dist",
"rewrites": [{ "source": "/api/**", "function": { "functionId": "prod-api", "codebase": "prod" } }]
}
]
}Full example: templates/firebase.codebases.example.json (copied under multi-env/ by init). Rules templates ship in templates/ (and via init). Gated DBs must check allowedEnvs; prod should not.
Cloud Functions (callables)
import {
createEnvRuntime,
createGetDb,
createGetDbForEnv,
requireAuth,
requireOwner,
} from 'firebase-multi-env/server';
import { createWithAppEnvV1 } from 'firebase-multi-env/functions-v1';
export const appEnvRuntime = createEnvRuntime({
pinned: true,
pinnedEnvironment: process.env.APP_ENV,
environments: {
production: {
database: '(default)',
origins: ['https://myapp.web.app', 'https://myapp.firebaseapp.com'],
},
qual: {
database: 'qual-env',
origins: ['https://myapp-qual.web.app'],
requireClaim: true,
},
cert: {
database: 'cert-env',
origins: ['https://myapp-cert.web.app'],
requireClaim: true,
},
},
});
export const getDb = createGetDb(appEnvRuntime);
export const getDbForEnv = createGetDbForEnv(appEnvRuntime);
export const withAppEnv = createWithAppEnvV1(appEnvRuntime);
export const syncData = functions.https.onCall(withAppEnv(async (data, context) => {
const auth = requireAuth(context.auth);
requireOwner(auth, data.userId);
const db = getDb();
// ...
}));Optional overrides: HOST_ORIGINS_<ENV> (comma-separated).
HTTP functions
import { createWithAppEnvHttp } from 'firebase-multi-env/http';
import { onRequest } from 'firebase-functions/v2/https';
// verifyIdToken is recommended: onRequest does not populate req.auth by default.
const withHttp = createWithAppEnvHttp(appEnvRuntime, { verifyIdToken: true });
export const api = onRequest(withHttp(async (req, res) => {
const db = getDb();
res.json({ env: appEnvRuntime.getEnvTag() });
}));Localhost hints: x-app-env header or ?appEnv=.
Web client
import { createMultiEnvClient } from 'firebase-multi-env/client';
import { getFunctions } from 'firebase/functions';
const appEnv = import.meta.env.VITE_APP_ENV;
const { callable, getDb } = createMultiEnvClient({
app,
functions: getFunctions(app),
appEnv,
// Must match firebase.json functions[].prefix (CLI deploys `${prefix}-${name}`)
prefixes: {
production: 'prod',
qual: 'qual',
cert: 'cert',
},
databases: {
production: '(default)',
qual: 'qual-env',
cert: 'cert-env',
},
});
await callable('syncData')({ /* payload */ }); // → qual-syncData when appEnv is "qual"
const db = getDb();Or use createCallable / resolveFunctionId / createGetClientFirestore individually.
Grant environment access
Auth is shared in one Firebase project. Non-prod access uses allowedEnvs claims:
gcloud auth application-default login
npx firebase-multi-env grant-env qual --project my-project [email protected]
npx firebase-multi-env grant-env cert --project my-project [email protected]
# → { allowedEnvs: ['qual', 'cert'] }
# sign out / sign in
npx firebase-multi-env grant-env qual --revoke --project my-project [email protected]Provision per-env IAM (scripts only)
Generate reviewable gcloud scripts for runtime SAs, Storage buckets, and secret accessors:
npx firebase-multi-env provision --project my-project --envs production,qual,cert
bash multi-env/provision/provision.all.shSee templates/PROVISION.md. Scripts do not call GCP until you run them.
What this package covers
- Origin → environment → Firestore database routing
- Pinned production path (unpinned blocked on Cloud deploys by default)
- Gated-env allowlist claims for Functions (callable + HTTP)
- Hardened Origin parsing (no multi-value /
null/ non-http schemes) - Optional Referer fallback (off by default when pinned)
- Emulator-env leak refusal on deployed pinned functions
getDbForEnvfor scripts/jobs; fail-closedgetDbwhen request context is requiredonResolveEnvaudit hook- Optional HTTP ID token verification
- Client callable + Firestore helpers (
prefixes/resolveFunctionIdfor firebase.jsonprefix) - Server guards (
requireAuth,requireOwner,requireClaim) - Rules templates (Firestore + Storage) +
init/doctor --strict - ESLint plugin (
no-bare-admin-firestore,require-pinned-runtime) - CLI grant/revoke for
allowedEnvs(shared Auth + claims) - CLI
provision— generate per-env SA / bucket / secret gcloud scripts - Templates for per-env SAs, secrets, deploy isolation, project-parity checklist
Still app-owned: Auth UI/sign-in flows, domain-specific RBAC, live IAM bindings, org policies, and full product security rules beyond the templates.
Releasing
Releases use semantic-release on main.
- npm granular token (read/write + bypass 2FA)
- GitHub Actions secret
NPM_SECRET(mapped toNPM_TOKENin the workflow) - Conventional Commits:
fix:patch,feat:minor,BREAKING CHANGE/feat!:major
License
MIT
