Deploying to Firebase from GitHub Actions Without a Service Account Key
- #GitHub
- #Firebase
- #Google Cloud
- #Security
- #CI/CD
Deploying to Firebase from GitHub Actions Without a Service Account Key
Most guides tell you to create a service account, download its JSON key, and paste it into a GitHub secret. It works on the first try, which is why it is everywhere. It also means a long lived credential that can write to your production project now exists as a file, in a password manager, in a shell history, and in whatever backup ran last night. Nothing expires it. Nothing tells you when it is used.
Workload Identity Federation removes the key entirely. GitHub already signs a token describing each workflow run: which repository, which branch, which workflow. You tell Google to trust those tokens under conditions you set, and Google hands back short lived credentials. There is no key to leak, rotate, or lose, because one never exists.
This is the whole process in the order it has to happen, with every command, and the traps that cost me time.
Written against these versions
Cloud tooling moves, so if something below does not match what you see, check these first. This was written on the date shown above using:
gcloud532.0.0firebase-tools14.27.0google-github-actions/auth@v2- Firebase project on the Blaze plan, Cloud Functions 2nd gen
The shape of the setup has been stable for a while. The flag names are the part most likely to drift.
The shape of it
Four things have to line up:
- A pool, which is a container for external identities.
- A provider inside that pool that trusts GitHub's token issuer, with a condition saying which workflows count.
- A service account holding the permissions a deploy needs.
- A binding letting the pool's identities act as that account.
The condition is the security boundary. Everything else is plumbing.
Before you start: the two values you will need
Almost every command below needs your project id, and two of them need the project number, which is different. Get both now:
gcloud projects describe PROJECT_ID --format="value(projectId,projectNumber)"Substitute throughout: PROJECT_ID is the readable id, PROJECT_NUMBER is the long digits, and OWNER/REPO is your GitHub repository path.
Step 1. Enable the APIs
gcloud services enable iamcredentials.googleapis.com sts.googleapis.com \
--project PROJECT_IDConfirm they are on:
gcloud services list --enabled --project PROJECT_ID \
--filter="config.name:(iamcredentials.googleapis.com OR sts.googleapis.com)" \
--format="value(config.name)"Step 2. Create the pool
gcloud iam workload-identity-pools create github \
--location=global \
--display-name="GitHub Actions" \
--description="Keyless CI auth" \
--project PROJECT_IDCheck what exists, which also tells you whether you are repeating a step:
gcloud iam workload-identity-pools list --location=global --project PROJECT_IDStep 3. Create the provider, with the condition
gcloud iam workload-identity-pools providers create-oidc github-actions \
--location=global \
--workload-identity-pool=github \
--display-name="GitHub Actions OIDC" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository == 'OWNER/REPO' && assertion.ref == 'refs/heads/production'" \
--project PROJECT_IDThis is the part worth slowing down for. Restricting by repository alone means any branch in that repository that can run Actions is able to deploy to production, including a branch someone opens a pull request from. Adding the ref clause means only that one branch can.
The tradeoff is real: a hotfix branch or a tag cannot deploy until you widen the condition. That is the same sentence as "nothing unexpected can deploy", which is why I took the narrow version.
Read it back, because this is the one setting you most want to be sure of:
gcloud iam workload-identity-pools providers describe github-actions \
--location=global --workload-identity-pool=github --project PROJECT_ID \
--format="value(attributeCondition,oidc.issuerUri,state)"Step 4. Create a dedicated deploy account
gcloud iam service-accounts create github-deploy \
--display-name="GitHub Actions deploy" \
--description="Keyless CI deploys via Workload Identity Federation" \
--project PROJECT_IDMake it a new account rather than reusing one you already have. It is tempting to point this at the Firebase Admin SDK account that already exists, since it has broad permissions and everything just works. That account can also read and write every document in your database. A deploy job has no business doing that, and if the identity is ever misused you want the blast radius to be "can deploy", not "can deploy and read every user's data".
Step 5. Create the custom role for Extensions
No predefined role grants only the Extensions read permissions, and the Firebase CLI calls that API on every deploy even when you have no extensions installed. Without it the deploy fails on something you are not using:
gcloud iam roles create ciDeployExtensionsRead \
--project PROJECT_ID \
--title="CI deploy: read Extensions" \
--description="firebase deploy lists extension instances" \
--permissions=firebaseextensions.instances.get,firebaseextensions.instances.list \
--stage=GAStep 6. Grant the deploy permissions
Functions need most of this list because a 2nd gen functions deploy builds a container and runs it on Cloud Run, so it touches Cloud Build, Artifact Registry, Cloud Run and Eventarc. None of those is implied by the others.
PROJECT_ID=your-project-id
SA="github-deploy@${PROJECT_ID}.iam.gserviceaccount.com"
for role in \
roles/firebase.viewer \
roles/firebaserules.admin \
roles/datastore.indexAdmin \
roles/firebasestorage.admin \
roles/cloudfunctions.admin \
roles/run.admin \
roles/cloudbuild.builds.editor \
roles/artifactregistry.writer \
roles/eventarc.admin \
roles/iam.serviceAccountUser \
roles/storage.admin \
roles/serviceusage.serviceUsageConsumer \
projects/${PROJECT_ID}/roles/ciDeployExtensionsRead
do
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${SA}" \
--role="$role" \
--condition=None \
--quiet > /dev/null && echo "granted $role" || echo "FAILED $role"
doneTrap: if your project already has any conditional IAM binding anywhere, add-iam-policy-binding refuses to run non interactively and every grant fails with the same message about conditions. Passing --condition=None fixes all of them at once. I lost time here because thirteen identical failures looked like a permissions problem rather than a flag problem.
Step 7. Let the pool act as the account
This binding is what makes the whole thing work.
gcloud iam service-accounts add-iam-policy-binding \
"github-deploy@PROJECT_ID.iam.gserviceaccount.com" \
--project PROJECT_ID \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/OWNER/REPO" \
--condition=NoneNote this uses the project number, not the project id. Mixing them up produces a binding that looks correct and never matches anything.
Step 8. The workflow
name: CI
on:
push:
branches: [main, production]
jobs:
deploy-prod:
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github/providers/github-actions
service_account: github-deploy@PROJECT_ID.iam.gserviceaccount.com
- name: Deploy Firestore rules
run: npx firebase deploy --only firestore:rules --project PROJECT_ID --non-interactive
- name: Deploy Firestore indexes
run: npx firebase deploy --only firestore:indexes --project PROJECT_ID --non-interactive
- name: Deploy Cloud Functions
run: npx firebase deploy --only functions --project PROJECT_ID --non-interactiveThere is no credentials_json. That absence is the entire point.
Deploy targets are separate steps on purpose. They need different permissions, and a combined command fails opaquely when only one of them is unauthorised. Splitting them means the failure names which target broke.
Traps worth knowing before you hit them
The id-token: write permission is not optional. Without it the runner cannot mint the token it is supposed to present, and the auth step fails in a way that reads like a permissions problem on the Google side. It is not. The token never existed.
Check the branch actually triggers the workflow. Mine ran on: push: branches: [main]. I added a job gated on the production branch and it silently never ran, because the workflow itself was not triggered by pushes to that branch. The job was correct and unreachable, which produces no error anywhere.
A dry run is not verification. firebase deploy --dry-run compiles your rules and reports success without exercising the write path, so it passes happily for an account that cannot actually deploy. I have claimed otherwise before and been wrong about it.
Verify without burning CI runs
Discovering missing permissions through failed CI runs is slow. On an earlier project it took four rounds of push, wait, read the error, grant one role. Ask the API directly instead. Grant yourself impersonation temporarily:
SA="github-deploy@PROJECT_ID.iam.gserviceaccount.com"
gcloud iam service-accounts add-iam-policy-binding "$SA" \
--project PROJECT_ID \
--member="user:you@example.com" \
--role=roles/iam.serviceAccountTokenCreator \
--condition=NoneThen ask what that account actually holds:
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token --impersonate-service-account=$SA)" \
-H "Content-Type: application/json" \
-d '{"permissions":[
"firebaserules.releases.create",
"firebaserules.rulesets.create",
"datastore.indexes.create",
"firebasestorage.defaultBucket.get",
"cloudfunctions.functions.create",
"run.services.update",
"artifactregistry.repositories.uploadArtifacts",
"eventarc.triggers.create",
"iam.serviceAccounts.actAs",
"cloudbuild.builds.create",
"storage.objects.create",
"firebaseextensions.instances.list"
]}' \
"https://cloudresourcemanager.googleapis.com/v1/projects/PROJECT_ID:testIamPermissions"The response lists only the permissions the account has. Anything you asked about that is missing from the response is missing from the account. Fix the gaps, then remove your own impersonation, because it was scaffolding rather than part of the design:
gcloud iam service-accounts remove-iam-policy-binding "$SA" \
--project PROJECT_ID \
--member="user:you@example.com" \
--role=roles/iam.serviceAccountTokenCreator \
--condition=NoneHow to tell it is really working
Two things should be true when you are done. First, no user managed keys exist on the account, which is the entire reason for doing this:
gcloud iam service-accounts keys list \
--iam-account="github-deploy@PROJECT_ID.iam.gserviceaccount.com" \
--managed-by=user \
--project PROJECT_IDAn empty result is success. Note that without --managed-by=user this lists Google's own rotating keys too, which are not the thing you are checking for.
Second, the only principal permitted to assume the account is the pool:
gcloud iam service-accounts get-iam-policy \
"github-deploy@PROJECT_ID.iam.gserviceaccount.com" \
--project PROJECT_IDYou should see exactly one binding, roles/iam.workloadIdentityUser, held by the principalSet:// member. If a human still appears there, you left your verification grant behind.
Doing this again on another project
- Get the project id and project number.
- Enable
iamcredentialsandsts. - Create the pool.
- Create the provider, deciding the attribute condition deliberately.
- Create a dedicated deploy account rather than reusing the Admin SDK one.
- Create the Extensions custom role.
- Grant the deploy roles, remembering
--condition=None. - Bind the principal set with
workloadIdentityUser, using the project number. - Add
id-token: writeto the job, and confirm the branch triggers the workflow at all. - Verify by impersonation, then revoke your own access.
The whole thing takes about twenty minutes once you know the shape. Most of the time I lost went to two things that fail quietly rather than loudly: a job that never ran because its branch was not a trigger, and a set of grants that all failed for a reason the error message did not explain.
0 Comments
Sign in to join the conversation.