Migrating a Firebase deploy from FIREBASE_TOKEN to Workload Identity Federation
Five days of silently failing deploys, an org policy blocking service account keys, and the path to a keyless GitHub Actions setup. A practical walkthrough with the gcloud commands that actually worked.
Written by Hong-Bin Yoon · Founder, zzinDev LLC
Published
A few weeks ago I noticed something odd. AnimeRecap’s content pipeline had been opening research and writer PRs every day for a week, I’d been merging them every day for a week, and the live site looked… exactly the same. Same recap count. Same “most recent post” date from before the week started. Content I’d reviewed and merged was committed to main but wasn’t on production.
The deploys were all green at a glance. git log on main showed every merge. But nothing had shipped. That’s the worst kind of failure: invisible.
This is what was actually happening, why, and what I did to fix it permanently.
The symptom
GitHub Actions summary page shows the “Deploy” workflow with a checkmark next to each recent run. You have to click into a run and scroll to the last step to notice it has a red X:
> npx firebase-tools@15.13.0 deploy --only hosting --project animerecap-prod --non-interactive
⚠ Authenticating with `FIREBASE_TOKEN` is deprecated and will be removed in a
future major version of `firebase-tools`. Instead, use a service account key
with `GOOGLE_APPLICATION_CREDENTIALS`: https://cloud.google.com/docs/authentication/getting-started
Authentication Error: Your credentials are no longer valid.
Please run `firebase login --reauth`
The overall workflow status is green because the deploy job has continue-on-error: true on a step somewhere upstream (I didn’t write the original workflow, and I’d missed that). The failure is at the last step, which is non-fatal to the workflow’s exit code. Every deploy had been failing for five days.
Two things were wrong:
- My
FIREBASE_TOKENhad expired. Tokens generated withfirebase login:cirotate on some Google-internal schedule that’s not documented in user-facing terms; in practice mine lasted about a year. - The whole
FIREBASE_TOKENauth flow is deprecated. Even if I’d rotated it, I’d be on borrowed time.
The easy fix that I didn’t take
The path of least resistance was to run firebase login:ci locally, paste the new token into GitHub Secrets, trigger the workflow, and move on. That would have shipped the backlog and bought me another year of token validity.
I didn’t do that because firebase-tools openly tells you it’s going away. Rotating a dying auth method to ride out a year felt like putting a patch on a flat tire that was about to get replaced anyway. If I was going to touch the deploy workflow, I was going to fix it properly.
The modern path is one of two things:
- A service account JSON key, stored as a secret, written to a file at runtime, referenced via
GOOGLE_APPLICATION_CREDENTIALS. - Workload Identity Federation (WIF), where GitHub Actions exchanges its own OIDC token for a short-lived GCP access token, no long-lived credentials anywhere.
I went with WIF. Here’s why, and how to set it up.
Why not a service account key
My Google Workspace org has a policy constraint turned on:
constraints/iam.disableServiceAccountKeyCreation
When I tried to generate a key in the Cloud Console, I got a FAILED_PRECONDITION error. When I retried via gcloud iam service-accounts keys create, same error. The org policy is inherited by every project, and overriding it at the project level requires orgpolicy.policyAdmin, which defeats the purpose of the policy.
The policy exists for a reason: long-lived JSON keys are the most common AWS/GCP leak vector. They end up in committed .env files, on abandoned laptops, in stack traces posted to support tickets. If you have a choice, don’t create one.
WIF is the keyless alternative. It’s the thing Google is recommending everyone migrate to.
How Workload Identity Federation works, briefly
The relationship looks like this:
- GitHub Actions, when it runs, gets an OIDC token from GitHub’s token service. That token has claims about the repo, branch, workflow name, etc.
- GCP is configured to trust GitHub’s OIDC issuer via a Workload Identity Pool Provider.
- The provider has an attribute condition — an expression that filters which OIDC tokens it accepts. The usual one is “repository owner matches X.”
- A service account in GCP has a trust binding that says “the principal matching these conditions may impersonate me.”
- At runtime, the
google-github-actions/auth@v2action exchanges GitHub’s OIDC token for a short-lived GCP access token via GCP’s Security Token Service (STS), and then impersonates the service account.
No keys anywhere. The GitHub OIDC token is ephemeral and scoped per-run. The GCP access token is ephemeral. If the GitHub workflow file is tampered with, the attribute condition can block the tampered run from authenticating.
The actual commands
This is for a single project, single repo, single service account setup. Adapt the names.
Enable the required APIs:
gcloud services enable \
iamcredentials.googleapis.com \
sts.googleapis.com \
iam.googleapis.com \
--project=<PROJECT_ID>
Create a workload identity pool:
gcloud iam workload-identity-pools create github-actions \
--project=<PROJECT_ID> \
--location=global \
--display-name="GitHub Actions"
Create an OIDC provider inside the pool, trusting GitHub:
gcloud iam workload-identity-pools providers create-oidc github \
--project=<PROJECT_ID> \
--location=global \
--workload-identity-pool=github-actions \
--display-name="GitHub" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
--attribute-condition="assertion.repository_owner == '<GITHUB_ORG>'" \
--issuer-uri="https://token.actions.githubusercontent.com"
The attribute-condition is important. Without it, any repo on GitHub could exchange tokens against your provider. Scope it to your org or to specific repos.
Bind the service account to the pool:
PROJECT_NUMBER=$(gcloud projects describe <PROJECT_ID> --format="value(projectNumber)")
gcloud iam service-accounts add-iam-policy-binding \
<SA_EMAIL> \
--project=<PROJECT_ID> \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-actions/attribute.repository/<GITHUB_ORG>/<REPO>"
The principalSet://... URL is the scariest-looking part. It says “principals whose attribute.repository claim is exactly this repo are authorized to impersonate this service account.”
That’s all the GCP-side setup. No key was created. No secret needs to exist on the GitHub side except the trust relationship you’ve just declared in the provider’s attribute condition.
The GitHub Actions side
The workflow needs two changes:
Grant the job an OIDC token. This is the part that’s easy to miss — without it, the auth step fails with a confusing “token unavailable” error.
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for Workload Identity Federation
Use google-github-actions/auth@v2 to exchange tokens and point the rest of the job at the resulting credentials:
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/github-actions/providers/github
service_account: <SA_EMAIL>
- name: Deploy
run: npx firebase-tools@15.13.0 deploy --only hosting --project <PROJECT_ID> --non-interactive
That’s it. The auth action writes a temporary credentials file and sets GOOGLE_APPLICATION_CREDENTIALS for subsequent steps. firebase-tools picks it up automatically — no FIREBASE_TOKEN environment variable, no flag, no configuration.
What went wrong during setup
Three things bit me.
The OIDC permission. I forgot id-token: write on the job’s permissions block the first time. The google-github-actions/auth step fails with a fairly clear error when you miss this, but it doesn’t jump out unless you’ve seen it before.
The attribute condition mismatch. My first provider config had assertion.repository_owner == 'hongbin8237' — matching my GitHub username. That’s correct for a user-owned repo. If you’re in a GitHub org or switch from user to org, you need to change this.
Firebase auth deprecation warning still shows. Even after migrating, firebase-tools prints a deprecation warning about FIREBASE_TOKEN in some outputs. It’s cosmetic — the tool is reminding you the feature is deprecated, not that you are using it. As long as GOOGLE_APPLICATION_CREDENTIALS is set and valid, it’s using that.
Was this worth the two hours
Roughly, yes. Breakdown of the time:
- 20 minutes to realize the deploys had been silently failing for a week (this is the worst part and should have been caught by alerting, which I’ve since set up).
- 15 minutes exploring the easy path and getting stopped by the org policy.
- 30 minutes reading GitHub’s WIF doc for GCP and Google’s WIF doc for GitHub Actions — they’re good docs, but they cross-reference each other and you bounce back and forth.
- 30 minutes actually running the commands, hitting the
id-token: writemistake, fixing it, seeing a green deploy. - 15 minutes backfilling: deleting the old
FIREBASE_TOKENsecret, pushing an empty commit to verify, cleaning up.
In exchange I no longer have a rotating secret in CI. The service account has the minimum role it needs (roles/firebasehosting.admin — actually I have roles/firebase.admin which is broader than strictly needed; tightening that is on the list). There’s no key file to leak. If I lose my laptop, nothing about the deploy pipeline changes.
If you’re about to do the same thing
A few notes I wish I’d seen upfront.
- If your org has
constraints/iam.disableServiceAccountKeyCreationturned on, just skip the key-based path. It’ll waste your time. - The
attribute-conditionis the thing that actually scopes trust. Don’t skip it. Don’t set it to something overbroad. - Use
principalSet://...for “any repo matching X” andprincipal://...for a single exact subject. I use principalSet withattribute.repositoryequal to my repo; you can make it more restrictive (specific branch, specific workflow) if you want tighter control. - The
roles/iam.workloadIdentityUserbinding is on the service account, not on the project. A lot of Stack Overflow answers get this wrong. - Delete the old
FIREBASE_TOKENsecret from the repo once WIF is working. Leaving dead secrets around is an audit-trail mess.
For an evening’s work you get a deploy pipeline that matches modern practice, removes a class of leaks, and stops silently failing every year when a token you forgot about expires. If you’re still on FIREBASE_TOKEN in 2026, move.
Spot an error or have a suggestion? Request an edit →