Replacing Local Terraform Google Cloud Authentication from ADC to 1Password + Service Account Impersonation
This page has been translated by machine translation. View original
Introduction
When managing GCP resources from a local PC using Terraform, I've been using gcloud auth application-default login (ADC) for authentication. While it's convenient, ADC leaves what is effectively an unlimited refresh token on your disk.
In recent years, supply chain attacks that embed malicious code in npm / pip / brew packages to steal local credentials have become frequent. If you happen to run malware that can read ~/.config/gcloud/, it could potentially allow manipulation of all your projects, including production, for an extended period. As a countermeasure to this risk, I've reviewed the authentication method.
Ideally, Terraform execution should be consolidated in CI/CD and made keyless using Workload Identity Federation. However, in our projects, we've determined that operations allowing direct application from local machines provide better day-to-day flexibility. Therefore, with the premise of maintaining local operations while making authentication secure, we've implemented a 1Password + Service Account Impersonation configuration.
Prerequisites
In our projects, instead of installing the terraform CLI directly on local machines, we use the hashicorp/terraform Docker image called via a wrapper script named run.sh.
./run.sh {environment_name} {command}
./run.sh dev plan
./run.sh prd apply
We do this for the following reasons:
- The terraform version can be fixed within
run.sh, eliminating environment differences - Environment variables and authentication information can be centralized in one place
Previous Authentication Method
Our previous authentication flow was as follows:
- Developers execute
gcloud auth application-default login - Log in to Google account in the browser
- Credentials are saved to
~/.config/gcloud/application_default_credentials.json run.shmounts this file to the Docker container for Terraform to use
docker run -it --rm \
-v $PWD:/work \
-v $HOME/.config/gcloud:/.config/gcloud \
-w /work \
-e GOOGLE_APPLICATION_CREDENTIALS=/.config/gcloud/application_default_credentials.json \
--entrypoint "/bin/sh" \
hashicorp/terraform:$version \
-c "cd environments/$environment && terraform $command"
Problems with ADC
The content of application_default_credentials.json is an OAuth refresh token. It has practically unlimited lifetime, and if stolen, the following becomes possible:
- Attackers can issue new access tokens at any time
- They can operate GCP with the same permissions as the actual user (including all projects with production)
- Forced revocation is cumbersome (remains valid unless the user re-logs in or revokes it)
If ~/.config/gcloud/ leaks through local malware or accidental file sharing, it could be exploited for extended periods without the user's knowledge.
New Authentication Method
Overview
Two-tier Service Account Structure
We prepare two SAs for each environment (dev / itg / prd).
| SA | Role | Permissions |
|---|---|---|
terraform-runner |
Actual Terraform operations | Project owner |
terraform-bootstrap |
Impersonation source (stored in 1Password) | TokenCreator for runner SA only |
The key point is that the bootstrap SA has no permissions at all except the ability to impersonate the runner SA. This provides the following benefits:
- Even if the bootstrap key leaks, it can't do anything on its own
- Actual operations are done via impersonation, so audit logs show "who impersonated whom"
Keys issued per individual (not shared)
Bootstrap SA keys are not shared with the team. Each member issues their own key and stores it in their personal 1Password.
This enables:
- When someone leaves or loses a device, you only need to revoke that user's key
- Operations can be tracked by key ID in audit logs
How run.sh Works
# 1. Create isolated gcloud config directory with automatic deletion on exit
gcloud_tmp=$(mktemp -d -t terraform-gcloud-XXXXXX)
chmod 700 "$gcloud_tmp"
trap 'rm -rf "$gcloud_tmp"' EXIT
export CLOUDSDK_CONFIG="$gcloud_tmp"
# 2. Get bootstrap SA key from 1Password (with Touch ID verification)
op_vault=$(op item get "$op_item" --format json | jq -er '.vault.name')
op read "op://${op_vault}/${op_item}/credential" > "$gcloud_tmp/bootstrap-key.json"
# 3. Activate in gcloud
gcloud auth activate-service-account --key-file="$gcloud_tmp/bootstrap-key.json"
# 4. Impersonate Runner SA to get a token valid for 1h
# Export token so only the variable name is passed to docker (to prevent value exposure in ps)
export GOOGLE_OAUTH_ACCESS_TOKEN
GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token \
--impersonate-service-account="$runner_sa")
# 5. Pass to Terraform via environment variable (no file mounting)
docker run -e GOOGLE_OAUTH_ACCESS_TOKEN ...
# When script ends, the trap set in step 1 deletes gcloud_tmp
Security Improvements
- No long-lived credentials are stored on disk
- Access tokens automatically expire after 1 hour, limiting impact if leaked
- 1Password with Touch ID ensures physical device is required for access
- Audit logs remain in both 1Password and GCP
- Can be immediately revoked on a per-user basis
Setup Instructions
1. Team Administrator Tasks (once per environment)
Set up Runner SA and Bootstrap SA for each environment (dev / itg / prd).
# === Change per environment ===
PROJECT_ID="my-project-dev" # Replace with GCP project ID for each environment
ENV_LABEL="dev" # dev / stg / prd, etc., to identify environment
# === Fixed ===
TFSTATE_BUCKET="${PROJECT_ID}-tfstate"
RUNNER_EMAIL="terraform-runner@${PROJECT_ID}.iam.gserviceaccount.com"
BOOTSTRAP_EMAIL="terraform-bootstrap@${PROJECT_ID}.iam.gserviceaccount.com"
gcloud config set project "$PROJECT_ID"
# Create Runner SA
gcloud iam service-accounts create terraform-runner \
--display-name="Terraform Runner ($ENV_LABEL)" \
--description="SA for Terraform execution. Impersonated by bootstrap SA"
# Create Bootstrap SA
gcloud iam service-accounts create terraform-bootstrap \
--display-name="Terraform Bootstrap ($ENV_LABEL)" \
--description="Source SA with key stored in 1Password"
# Grant project management permissions to Runner SA
# To avoid interactive prompts when project has existing conditional bindings,
# explicitly specify --condition=None
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${RUNNER_EMAIL}" \
--role="roles/owner" \
--condition=None
# Grant state bucket permissions to Runner SA
gcloud storage buckets add-iam-policy-binding "gs://${TFSTATE_BUCKET}" \
--member="serviceAccount:${RUNNER_EMAIL}" \
--role="roles/storage.objectAdmin"
# Grant ONLY "impersonate Runner SA" permission to Bootstrap SA
gcloud iam service-accounts add-iam-policy-binding "$RUNNER_EMAIL" \
--member="serviceAccount:${BOOTSTRAP_EMAIL}" \
--role="roles/iam.serviceAccountTokenCreator"
The key point is to give Bootstrap SA no permissions other than impersonating Runner SA. This ensures that even if the key leaks, nothing can be done without going through the Runner SA.
2. Each Member's Tasks (issue personal keys for each environment)
Each member issues their own bootstrap SA key and stores it in their personal 1Password.
2-1. 1Password CLI Setup
# For macOS
brew install --cask 1password-cli
Enable Settings → Developer → Integrate with 1Password CLI in the 1Password app to unlock the op command with Touch ID.
2-2. Issue Bootstrap SA Key and Store in 1Password
Execute the following for each of the dev / itg / prd environments:
# === Change per environment ===
PROJECT_ID="my-project-dev"
ENV_LABEL="dev"
# === Common ===
BOOTSTRAP_EMAIL="terraform-bootstrap@${PROJECT_ID}.iam.gserviceaccount.com"
KEY_FILE=$(mktemp -t bootstrap-${ENV_LABEL})
# Issue personal key
gcloud iam service-accounts keys create "$KEY_FILE" \
--iam-account="$BOOTSTRAP_EMAIL"
# Store in 1Password (using JSON template + stdin to avoid exposing in command line)
jq -n \
--arg title "tf-bootstrap-${ENV_LABEL}" \
--rawfile cred "$KEY_FILE" \
'{
title: $title,
category: "API_CREDENTIAL",
fields: [
{id: "credential", type: "CONCEALED", label: "credential", value: $cred}
]
}' \
| op item create -
# Safely delete local temporary file
rm -P "$KEY_FILE"
We avoid using assignment statements to pass sensitive values like op item create "credential[password]=$(cat $KEY_FILE)". The official 1Password documentation explains:
Command arguments can be visible to other processes on your machine. If you're assigning sensitive values, use an item JSON template instead.
We properly escape the SA key JSON as a JSON string using jq --rawfile and pipe it through stdin to op item create - so the value doesn't appear in the command line.
1Password item names should follow the tf-bootstrap-{env} format. The run.sh will search for this name, so changing it will break functionality. You can use any personal vault (like Private) of your choice.
3. Delete Old ADC Files
After switching to the new method, delete the now unnecessary files:
gcloud auth application-default revoke
rm -f $HOME/.config/gcloud/application_default_credentials.json
Conclusion
While ADC files were "convenient but dangerous," simply distributing SA keys directly poses different risks. The combination of 1Password + impersonation achieves:
- No long-lived secrets stored on disk
- Actual operations performed with short-lived tokens
- Per-user issuance and revocation
- Trackability through audit logs
Note that service account keys have a default limit of 10 keys per SA. This configuration with a single bootstrap SA and keys issued per team member works well for small to medium-sized teams. For larger teams, consider separating the bootstrap SAs per user (naming them like terraform-bootstrap-{username}), adapting the configuration to your team's situation.
I hope this has been helpful.