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 the secrets already in GCP Secret Manager (and the public Firebase config available via the Firebase Management API), you can populate .env.local automatically:
# One-time: Google account β GCP Secret Manager
gcloud auth application-default login
# One-time: Google account β Firebase Management API (for VITE_FIREBASE_*)
firebase 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, calls firebase apps:sdkconfig WEB for the public Firebase config, and merges everything into your .env.local.
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 β auto-fetched via firebase apps:sdkconfig WEB (falls back to manual copy if firebase-tools isn't installed) |
| 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 + Firebase Management API, ~2 min):
gcloud auth application-default login # one-time, for Secret Manager
firebase login # one-time, for VITE_FIREBASE_*
npm run env:bootstrap # done β pulls 25 valuesIf firebase-tools isn't installed, the script gracefully degrades and prints the npm install -g firebase-tools hint β only VITE_FIREBASE_* need manual copy in that case.
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.