Secrets Management โ Lantern โ
Lantern uses GCP Secret Manager as its single source of truth for all runtime secrets. No third-party secret broker (Infisical, Vault, etc.) is needed because the entire backend already runs on Google Cloud (Firebase Cloud Functions, Cloud Run, Cloud IAM).
Why GCP Secret Manager? โ
| Concern | Answer |
|---|---|
| Already on GCP? | Yes โ Firebase, Cloud Run, Cloud Functions all live in GCP projects. |
| Keyless auth from CI | Workload Identity Federation (WIF) lets GitHub Actions authenticate without a stored JSON key. |
| Audit trail | Every secret access is logged in Cloud Audit Logs. |
| Version history | Secrets are versioned; roll back or rotate without redeploying. |
| Cost | Negligible โ free tier covers normal usage. |
Adding a third-party broker like Infisical would introduce an extra hop, another vendor dependency, and another credential to manage. GCP Secret Manager already satisfies the requirement.
Secrets by layer โ
1. Firebase Cloud Functions (server-side) โ
All sensitive values consumed by Cloud Functions are stored in Secret Manager and referenced via defineSecret from firebase-functions/params.
// services/functions/firebase/config.js
import { defineSecret } from 'firebase-functions/params'
const githubToken = defineSecret('GITHUB_TOKEN')
const discordWebhookUrl = defineSecret('DISCORD_WEBHOOK_URL')
const resendApiKey = defineSecret('RESEND_API_KEY')
const cloudflareApiToken = defineSecret('CLOUDFLARE_API_TOKEN')
const cloudflareZoneId = defineSecret('CLOUDFLARE_ZONE_ID')
const cloudflareAccountId = defineSecret('CLOUDFLARE_ACCOUNT_ID')
const railwayApiToken = defineSecret('RAILWAY_API_TOKEN')Each function that needs a secret must declare it in its options:
export const myFunction = onCall(
{ ...callableOptions, secrets: [githubToken, discordWebhookUrl] },
async (request) => {
// Firebase injects the value into process.env at runtime:
const token = process.env.GITHUB_TOKEN
}
)Firebase handles the Secret Manager IAM grant automatically when you firebase deploy โ the Cloud Run service account for that function is granted roles/secretmanager.secretAccessor for the declared secrets.
โ ๏ธ If a function calls
getConfig()or readsprocess.env.GITHUB_TOKENwithout declaringsecrets: [githubToken], the value will beundefinedat runtime (Secret Manager values are not in the environment by default).
Adding a new secret to Cloud Functions โ
# 1. Create the secret (dev project)
echo -n "my-value" | gcloud secrets create MY_SECRET \
--data-file=- \
--project=lantern-app-dev
# 2. Create the secret (prod project)
echo -n "my-value" | gcloud secrets create MY_SECRET \
--data-file=- \
--project=lantern-app-prod
# 3. Register it in config.js
const mySecret = defineSecret('MY_SECRET')
export { mySecret }
# 4. Declare it on every function that needs it
{ ...callableOptions, secrets: [mySecret] }
# 5. Deploy
firebase deploy --only functions --project lantern-app-dev2. Cloud Run services (server-side) โ
Cloud Run services authenticate to GCP using the attached service account (Workload Identity Federation from CI, or the default compute SA on Cloud Run).
For secrets needed at runtime, use --update-secrets when deploying:
gcloud run deploy my-service \
--update-secrets=MY_SECRET=MY_SECRET:latest \
--region us-central1 \
--project lantern-app-devThis mounts the secret as an environment variable without it appearing in the Cloud Run configuration UI or deployment logs.
Current Cloud Run services (venue-api, analytics-api, lanterns-api, docs-api) only require non-sensitive env vars (
FIREBASE_PROJECT_ID,NODE_ENV). Add--update-secretswhen a sensitive value is needed.
3. GitHub Actions CI/CD (build-time) โ
GitHub repository secrets (Settings โ Secrets and variables โ Actions) are used only for values that must be present at build time (e.g. VITE_* Firebase web config keys baked into the Vite bundle).
| Secret category | Where stored | Notes |
|---|---|---|
Firebase web config (VITE_*) | GitHub Secrets | Public keys โ safe in bundle; Firebase restricts by domain/security rules |
| Cloudflare deploy token | GitHub Secrets | Used by wrangler-action during deployment |
| Firebase CLI token | GitHub Secrets | FIREBASE_TOKEN for deploying rules/functions |
| GCP auth (Cloud Run deploys) | WIF โ no stored key | WIF_PROVIDER_DEV/PROD + WIF_SERVICE_ACCOUNT_DEV/PROD |
| Runtime secrets | GCP Secret Manager | Never passed through GitHub Actions |
WIF (Workload Identity Federation) means there is no JSON service account key stored in GitHub Secrets for any GCP operation performed during deployment. This is already the pattern in all deploy-dev.yml and deploy-prod.yml workflows.
Local development โ replacing .env.local โ
.env.local has historically required developers to manually copy tokens from dashboards, shared docs, or colleagues. With everything (including the public Firebase config) in GCP Secret Manager, a single gcloud auth populates .env.local automatically โ no firebase login, no firebase-tools:
# One-time: Google account โ GCP Secret Manager
gcloud auth application-default login
# Pull all shared values into .env.local
npm run env:bootstrapThe bootstrap script (tooling/scripts/bootstrap-env.mjs) connects to the lantern-app-dev GCP project, fetches every secret listed in SECRET_MAP (which now includes the public VITE_FIREBASE_* config), and merges everything into your .env.local.
๐ฎ Zero-auth boot. The public Firebase config is also committed in
/.env.development, sonpm run devworks in a fresh checkout / Codespace before you authenticate. See Mirroring the public Firebase config.
What gets bootstrapped vs. what's manual โ
| Category | Examples | How to get |
|---|---|---|
| Server-side secrets | Cloudflare tokens, Resend/Anthropic API keys, GitHub App credentials, reCAPTCHA keys, Railway token, Discord webhook, email encryption key | npm run env:bootstrap โ pulls from GCP Secret Manager |
| Public Firebase config | VITE_FIREBASE_* (API key, auth domain, project ID, etc.) | npm run env:bootstrap โ pulled from Secret Manager. Committed fallback in /.env.development for zero-auth boot |
| Manual (per-developer) | GH_PAT (your personal GitHub token), GOOGLE_APPLICATION_CREDENTIALS | Set by hand โ these differ per developer |
| Other public config | API origins (VENUE_API_ORIGIN etc.), GITHUB_REPO | Set once from .env.local.example โ project-shape constants |
๐ก The
GOOGLE_APPLICATION_CREDENTIALSpath can be avoided entirely. Runninggcloud auth application-default logincreates ADC credentials that Firebase Admin SDK andgcloudCLI discover automatically โ no explicit path needed in.env.localfor most tooling scripts.
Additional bootstrap flags โ
# Preview what would be written (no file changes)
npm run env:bootstrap:dry-run
# Overwrite values that are already set (e.g. after a rotation)
npm run env:bootstrap:force
# Use the prod project instead of dev
node tooling/scripts/bootstrap-env.mjs --project=lantern-app-prodIAM requirement โ
The bootstrap script needs roles/secretmanager.secretAccessor on the lantern-app-dev project. Ask a project owner to grant your Google account:
gcloud projects add-iam-policy-binding lantern-app-dev \
--member="user:you@example.com" \
--role="roles/secretmanager.secretAccessor"Adding a new secret to the bootstrap flow โ
- Create the secret in both GCP projects (see "Adding a new secret" above).
- Add an entry to
SECRET_MAPintooling/scripts/bootstrap-env.mjs:jsMY_NEW_VAR: 'MY_SECRET_NAME', // .env.local key โ GCP secret name - Add a
[secret โ bootstrap fills this]annotation to.env.local.example. - Run
npm run env:syncto keep.env.local.exampleordered correctly.
Before vs. after โ
Before (manual setup, ~15 min):
- Ask a teammate for the Cloudflare token
- Log into Resend, copy the API key
- Find the Discord webhook URL in the server settings
- Copy 7
VITE_FIREBASE_*values from Firebase Console one at a time - Repeat for every other tokenโฆ
After (with Secret Manager, ~1 min):
gcloud auth application-default login # one-time, for Secret Manager
npm run env:bootstrap # done โ pulls every value, incl. VITE_FIREBASE_*No firebase login and no firebase-tools install required โ the public Firebase config is mirrored into Secret Manager alongside the real secrets.
Mirroring the public Firebase config โ
The VITE_FIREBASE_* values are public โ they ship in the browser bundle of the live site and are restricted by Firebase Security Rules + Authorized Domains, not by secrecy (see Threat model notes). They are mirrored into Secret Manager purely for onboarding ergonomics: it lets a single gcloud auth application-default login populate everything, with no dependency on firebase-tools. This is not a privacy boundary.
Two delivery paths, by design:
Committed fallback โ
/.env.development: a tracked file holding ONLY these public identifiers. Vite loads it indevelopmentmode (never in a production build), so a fresh checkout or Codespace bootsnpm run devwith zero auth. A path-anchored!/.env.developmentexception in.gitignoreun-ignores just this root file.โ ๏ธ NEVER add a real secret to
/.env.development. It is the one intentional exception to the "never commit env files" rule, and it holds public client identifiers only.Secret Manager (overrides the fallback once you authenticate): the 7 values are stored as individual secrets (secret name == env var name) in both projects, and listed in
SECRET_MAP.npm run env:bootstrapwrites them into.env.local, which Vite ranks above/.env.development.
Creating / rotating the mirrored secrets โ
for proj in lantern-app-dev lantern-app-prod; do
for var in VITE_FIREBASE_API_KEY VITE_FIREBASE_AUTH_DOMAIN VITE_FIREBASE_PROJECT_ID \
VITE_FIREBASE_STORAGE_BUCKET VITE_FIREBASE_MESSAGING_SENDER_ID \
VITE_FIREBASE_APP_ID VITE_FIREBASE_MEASUREMENT_ID; do
# Create (first time) โ use `versions add` instead if the secret already exists.
printf '%s' "<value-for-$proj>" | gcloud secrets create "$var" --data-file=- --project="$proj"
done
doneGet the values from Firebase Console โ Project Settings โ Your apps, or firebase apps:sdkconfig WEB --project <proj> --json. After mirroring, also paste the dev values into /.env.development (prod values are never committed).
Local development โ
Copy .env.local.example to .env.local and fill in your dev values:
cp .env.local.example .env.localLocal Cloud Functions development uses .runtimeconfig.json (gitignored) or the Firebase Emulator with .env files in services/functions/firebase/. See the Cloud Functions emulator docs for details.
Rotation procedure โ
- Create a new secret version in GCP Secret Manager (UI or
gcloud secrets versions add). - Disable the old version once the new deployment is confirmed healthy.
- For GitHub Actions secrets, update the value in
Settings โ Secrets. - Redeploy the affected function/service if not using
:latestauto-resolution.
Threat model notes โ
- Secrets never leave GCP for Cloud Functions/Cloud Run workloads.
- VITE_ values are intentionally public* โ they identify the Firebase project to the browser SDK. Restrict them via Firebase security rules and Authorized Domains in the Firebase console, not by keeping them secret.
- Audit logs: enable Cloud Audit Logs โ Data Access for
secretmanager.googleapis.comin both GCP projects to capture everyaccessSecretVersioncall.