DEV Community

Cover image for From a Leaked Pipeline Credential to Bulk Data
Andrew Stevens for Sakura Sky

Posted on Originally published at sakurasky.com

From a Leaked Pipeline Credential to Bulk Data

Your dbt job's service account has a key file sitting on a shared drive the early data team used, and another copy in a former contractor's laptop backup. The key has no expiry date. Key creation has been blocked in your organisation for a while, but this key predates the block, and it still gets an access token today. Whoever has the file can use the job's access to your BigQuery datasets, unless a VPC Service Controls perimeter limits where the account's requests can come from.

If your platform is older than your key-creation policy, it's worth assuming at least one key like this exists until an inventory says otherwise.

In brief. Blocking key creation doesn't retire keys made before the block. A legacy pipeline key can read, and possibly export, curated BigQuery data. BigQuery's Data Access logs, the record of which tables were read, with which key, and from where, stay in the _Default bucket for 30 days unless you change it, so after that you may not be able to say what a key read. This week: export your service account key inventory with Cloud Asset Inventory, find the user-managed keys with no expiry, and check when each one last authenticated.

This series treats the data platform's own machinery as the target: the service accounts that run jobs, the orchestrator, CI for SQL and dbt, connectors, keys, and exports. It uses the same example platform as The Lakehouse in Production: Microsoft Entra ID for workforce identity, Google Cloud for the data platform (BigQuery, Datastream, Cloud Composer, and dbt), and Azure SQL Database at source. It's a composite, and it already has the hardening from that series' first part, including an organisation policy that blocks key creation and upload.

Part 1 follows one credential to the data it can reach, then goes through the controls that narrow that path, roughly in the order they'd pay off on the example platform. The first one is an inventory, and the rest of the series builds on it.

Why blocking key creation didn't fix this

Google Cloud has two pairs of organisation policy constraints that stop service account keys at the source. iam.managed.disableServiceAccountKeyCreation and its legacy form iam.disableServiceAccountKeyCreation block new keys. iam.managed.disableServiceAccountKeyUpload and iam.disableServiceAccountKeyUpload block uploaded public keys. Organisations created on or after 3 May 2024 get the legacy pair enforced by default (Google Cloud, n.d.aa).

They only apply going forward. Google says they "are not retroactive; they do not affect previously created and configured service accounts" (Google Cloud, n.d.aa). iam.serviceAccountKeyExpiryHours caps key lifetime, but only for keys created after it applies (Google Cloud, n.d.aa). So a key made in the platform's early days keeps working until someone disables or deletes it.

We tested this in a sandbox project in October 2026. With creation blocked, a request for a new key failed with FAILED_PRECONDITION. A key we'd made during a short, approved exception still got a fresh token after the block went back on. We used a client that always asks for a new token, because gcloud can return a cached one, which says nothing about whether the key still works.

So the hardening stops new keys, subject to any exceptions, and finding the old ones is separate work.

From the file to the data

Each hop below says what the grant allows and what record it leaves. Figure 1 shows the same path and records.

Figure 1. A sequence diagram of one legacy key's path across five columns: whoever has the key, Google's token service, BigQuery, audit logs, and a destination bucket. 1: the key file sits on a share or backup, and storing or copying it leaves no record in Google Cloud. 2: it authenticates as the dbt job's account and gets an access token, typically valid for an hour; the Key Authentication Events metric counts the key's authentications. 3: the account holds Data Editor on the datasets and Job User on the project; grant changes are recorded in Admin Activity. 4: query jobs read silver and gold, and BigQuery writes Data Access entries that can carry serviceAccountKeyName, kept 30 days by default in _Default unless changed or routed. 5: data leaves as query results or through an extract job to the bucket; the job gets a Data Access entry, and the bucket's owner logs the write, if at all.

Hop 1: the file. The key is a JSON credentials file made for dbt's BigQuery service-account method, which points at it with a keyfile field (dbt Labs, n.d.b). Files like this tend to outlive the projects they were made for. They turn up on a shared drive from a migration, in a laptop backup, or on a branch nobody deleted, and storing or copying them usually leaves no record in Google Cloud. The file store may keep its own access logs, which are worth pulling during an investigation. ATT&CK calls this Credentials In Files, searching "local file systems and remote file shares for files containing insecurely stored credentials" (MITRE, 2026b).

Hop 2: the token. Whoever has the file can authenticate as the service account. Each access token typically expires after an hour (Google Cloud, n.d.ab), but the key doesn't, so it can be used again for as long as it stays valid. Each authentication with the key is counted by the Key Authentication Events metric (Google Cloud, n.d.e). Google can automatically disable keys it finds exposed publicly, and that's on by default (Google Cloud, n.d.aa; Google Cloud, n.d.w), but a private share or a backup isn't public, so it's unlikely to help here. From this point the activity looks like the account's own, which ATT&CK files under valid cloud accounts (MITRE, 2026a). Audit log entries for its calls record the key in authenticationInfo.serviceAccountKeyName, next to the account's email in principalEmail, for services that log it. Google notes that some, including App Engine and Compute Engine, don't (Google Cloud, n.d.o).

Hop 3: the grants. dbt's documentation lists BigQuery Data Editor, BigQuery User, and BigQuery Job User as "the typical starting point" for dbt access (dbt Labs, n.d.b). Data Editor has bigquery.tables.getData, bigquery.tables.updateData, and bigquery.tables.export, and can be granted on a dataset or a single table. Job User has bigquery.jobs.create and can only be granted on a project, folder, or organisation (Google Cloud, n.d.g). On the example platform the job has Data Editor on the platform's datasets and Job User on the project. BigQuery User would add more, and Part 2 measures how much. A dbt job that builds the silver and gold layers (the cleaned and business-ready tables) usually needs to read the layers below and write its own outputs, so the key can reach most of the curated data.

Job User decides where the account can run jobs, not what it can read. bigquery.tables.getData alone lets it read a table's rows through the API's tabledata.list method (Google Cloud, n.d.u), and a query job can be billed to a different project from the one that holds the data (Google Cloud, n.d.f). So removing Job User from your project doesn't contain the key; the dataset grants do. Data Editor can also change and delete those tables, so the key is an integrity risk as well as a confidentiality one. Changes to table data appear as TableDataChange entries in Data Access (Google Cloud, n.d.f).

Hop 4: the reads. A query job reads the tables the account can see. ATT&CK's Databases sub-technique covers this kind of collection (MITRE, 2026c). BigQuery records table reads as TableDataRead entries in the Data Access log, including reads through tabledata.list that run no job, and the job itself as JobInsertion and JobChange entries (Google Cloud, n.d.f). When a job is billed to another project, the read is logged in the project that holds the data while the job's own entries go to the billing project, and the billing project's ID is redacted there unless the two projects share a domain or an organisation (Google Cloud, n.d.f). For BigQuery those logs are on by default and land in the _Default bucket unless you've routed them elsewhere (Google Cloud, n.d.i), and _Default keeps them for 30 days unless someone has changed it (Google Cloud, n.d.j).

Hop 5: the data leaves. The same grants allow more than queries. Exporting a table to Cloud Storage needs bigquery.tables.export, bigquery.jobs.create, and write access to the destination bucket (Google Cloud, n.d.q). The roles above cover the first two. The third is granted on the bucket, and that bucket can sit in a project or organisation you don't control, so your own allow policies don't limit it. A VPC Service Controls perimeter around BigQuery is the usual control for that path. A Principal Access Boundary policy on the account may also keep it to resources in your organisation, for services that enforce one. Any log of the write to the bucket belongs to the bucket's owner.

In ATT&CK, one of the Databases sub-technique's detection analytics names BigQuery among the platforms to watch for unusual table exports (MITRE, 2026c). Transfer Data to Cloud Account covers moving data to another account on the same provider (MITRE, 2025), and applying it to a BigQuery export is our reading. Part 5 covers the export, copy, transfer, and connection paths, and the perimeter controls around them.

None of these hops needs a vulnerability; each uses a permission the account already holds.

Other credentials like this

A legacy key is the clearest example, but the pattern shows up elsewhere. Application-default credentials on an engineer's laptop let tools act as that engineer, with everything the engineer can reach, and the key constraints generally don't apply to them. Later parts cover a short-lived token printed in a CI log (Part 3), an Airflow connection holding a key (Part 4), and a connector vendor's credential (Part 6).

Controls that narrow the path

These are ordered roughly by how much each one narrows this path on the example platform. The right order for you depends on what you already have, your team, and your tooling, and none of them closes the path on its own.

1. Find the keys you already have

The constraint stops new keys. To find the old ones, Cloud Asset Inventory can export service accounts (iam.googleapis.com/ServiceAccount) and their keys (iam.googleapis.com/ServiceAccountKey) as they stood at a point in time you choose (Google Cloud, n.d.p; Google Cloud, n.d.b). Ours came out as newline-delimited JSON, one asset per line. Each key record had its type, origin, and validity window, and disabled keys had "disabled": true.

Listing 1 reads that file. For each user-managed key it reports the key's age, whether it ever expires, whether it's disabled or was uploaded, and whether its account is in the same export. It also writes out the graph the series builds on: accounts and keys as nodes, a disabled flag on each key, and an edge from each key to the account it authenticates as. It skips Google-managed keys. Google holds their private halves in escrow and nobody can access them directly (Google Cloud, n.d.ab), and using them to act as an account is impersonation, which Part 2 covers.

Python

"""Credential inventory over a Cloud Asset Inventory export (Part 1).

Reads newline-delimited JSON exported with --content-type=resource and emits
the series graph format: principals, credentials, and the edges between them,
plus one finding per user-managed key.
"""
import json
import sys
from datetime import datetime, timezone

SERVICE_ACCOUNT = "iam.googleapis.com/ServiceAccount"
KEY = "iam.googleapis.com/ServiceAccountKey"

def parse_time(value):
    # validAfterTime looks like 2023-03-02T09:20:11Z; a bare date means UTC midnight
    parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)

def inventory(lines, as_of):
    accounts, keys = {}, []
    for line in lines:
        if not line.strip():
            continue
        asset = json.loads(line)
        data = asset["resource"]["data"]
        if asset["asset_type"] == SERVICE_ACCOUNT:
            accounts[data["uniqueId"]] = data["email"]
        elif asset["asset_type"] == KEY and data.get("keyType") == "USER_MANAGED":
            parent_id = asset["resource"]["parent"].rsplit("/", 1)[1]
            keys.append((parent_id, data, asset.get("ancestors") or []))

    nodes = {}
    for email in accounts.values():
        principal = f"serviceAccount:{email}"
        nodes[principal] = {"id": principal, "kind": "principal",
                            "attrs": {"in_export": True}}

    edges, findings = [], []
    for parent_id, data, ancestors in keys:
        email = data["name"].split("/serviceAccounts/")[1].split("/keys/")[0]
        principal = f"serviceAccount:{email}"
        credential = f"key:{data['name']}"
        in_export = parent_id in accounts
        # A key with no end time is treated as never expiring, so it's reported
        ends = data.get("validBeforeTime", "9999-")
        no_expiry = ends.startswith("9999-")
        nodes.setdefault(principal, {"id": principal, "kind": "principal",
                                     "attrs": {"in_export": in_export}})
        nodes[credential] = {"id": credential, "kind": "credential",
                             "attrs": {"key_type": "USER_MANAGED",
                                       "disabled": bool(data.get("disabled"))}}
        edges.append({"from": credential, "to": principal,
                      "type": "authenticates_as", "evidence": "asset-export"})
        findings.append({
            "key": data["name"],
            "account": email,
            "age_days": (as_of - parse_time(data["validAfterTime"])).days,
            "no_expiry": no_expiry,
            "expired": not no_expiry and parse_time(ends) <= as_of,
            "disabled": bool(data.get("disabled")),
            "uploaded": data.get("keyOrigin") == "USER_PROVIDED",
            "account_in_export": in_export,
            # Audit logs name the key with this prefix (serviceAccountKeyName)
            "log_key_name": f"//iam.googleapis.com/{data['name']}",
            "project": data["name"].split("/")[1],
            "ancestors": ancestors,
        })

    return {
        "format": "pas-graph/1",
        "nodes": sorted(nodes.values(), key=lambda n: n["id"]),
        "edges": sorted(edges, key=lambda e: (e["from"], e["to"], e["type"])),
        "findings": sorted(findings, key=lambda f: f["key"]),
    }

if __name__ == "__main__":
    path, as_of = sys.argv[1], parse_time(sys.argv[2])
    with open(path, encoding="utf-8") as export:
        print(json.dumps(inventory(export, as_of), indent=2, sort_keys=True))
Enter fullscreen mode Exit fullscreen mode

Rust

//! Credential inventory over a Cloud Asset Inventory export (Part 1).
//!
//! Reads newline-delimited JSON exported with --content-type=resource and
//! emits the series graph format: principals, credentials, and the edges
//! between them, plus one finding per user-managed key.
use chrono::{DateTime, NaiveDate, Utc};
use serde_json::{json, Value};
use std::collections::BTreeMap;

const SERVICE_ACCOUNT: &str = "iam.googleapis.com/ServiceAccount";
const KEY: &str = "iam.googleapis.com/ServiceAccountKey";

pub fn parse_time(value: &str) -> DateTime<Utc> {
    // validAfterTime looks like 2023-03-02T09:20:11Z; a bare date means UTC midnight
    match DateTime::parse_from_rfc3339(value) {
        Ok(t) => t.with_timezone(&Utc),
        Err(_) => NaiveDate::parse_from_str(value, "%Y-%m-%d").expect("RFC 3339 timestamp or date")
            .and_hms_opt(0, 0, 0).unwrap().and_utc(),
    }
}

fn text<'a>(v: &'a Value, field: &str) -> &'a str {
    v[field].as_str().unwrap_or("")
}

pub fn inventory<'a>(lines: impl Iterator<Item = &'a str>, as_of: DateTime<Utc>) -> Value {
    let mut accounts = BTreeMap::new();
    let mut keys = Vec::new();
    for line in lines.filter(|l| !l.trim().is_empty()) {
        let asset: Value = serde_json::from_str(line).expect("one JSON asset per line");
        let data = asset["resource"]["data"].clone();
        match text(&asset, "asset_type") {
            SERVICE_ACCOUNT => {
                accounts.insert(text(&data, "uniqueId").to_string(), text(&data, "email").to_string());
            }
            KEY if text(&data, "keyType") == "USER_MANAGED" => {
                let parent = text(&asset["resource"], "parent");
                let (_, parent_id) = parent.rsplit_once('/').expect("parent is a resource name");
                let ancestors = asset["ancestors"].clone();
                keys.push((parent_id.to_string(), data, ancestors));
            }
            _ => {}
        }
    }

    let mut nodes = BTreeMap::new();
    for email in accounts.values() {
        let principal = format!("serviceAccount:{email}");
        nodes.insert(principal.clone(),
            json!({"id": principal, "kind": "principal", "attrs": {"in_export": true}}));
    }

    let (mut edges, mut findings) = (Vec::new(), Vec::new());
    for (parent_id, data, ancestors) in &keys {
        let name = text(data, "name");
        let email = name.split("/serviceAccounts/").nth(1).expect("key name holds its account")
            .split("/keys/").next().unwrap_or("");
        let principal = format!("serviceAccount:{email}");
        let credential = format!("key:{name}");
        let in_export = accounts.contains_key(parent_id);
        let created = parse_time(text(data, "validAfterTime"));
        // A key with no end time is treated as never expiring, so it's reported
        let ends = data["validBeforeTime"].as_str().unwrap_or("9999-");
        let no_expiry = ends.starts_with("9999-");
        nodes.entry(principal.clone()).or_insert_with(||
            json!({"id": principal, "kind": "principal", "attrs": {"in_export": in_export}}));
        nodes.insert(credential.clone(),
            json!({"id": credential, "kind": "credential", "attrs": {"key_type": "USER_MANAGED",
                "disabled": data["disabled"].as_bool().unwrap_or(false)}}));
        edges.push(json!({"from": credential, "to": principal,
                          "type": "authenticates_as", "evidence": "asset-export"}));
        findings.push(json!({
            "key": name,
            "account": email,
            // Whole days, rounded down, to match Python's timedelta.days
            "age_days": (as_of - created).num_microseconds().expect("age in range")
                .div_euclid(86_400_000_000),
            "no_expiry": no_expiry,
            "expired": !no_expiry && parse_time(ends) <= as_of,
            "disabled": data["disabled"].as_bool().unwrap_or(false),
            "uploaded": text(data, "keyOrigin") == "USER_PROVIDED",
            "account_in_export": in_export,
            // Audit logs name the key with this prefix (serviceAccountKeyName)
            "log_key_name": format!("//iam.googleapis.com/{name}"),
            "project": name.split('/').nth(1).unwrap_or(""),
            "ancestors": if ancestors.is_array() { ancestors.clone() } else { json!([]) },
        }));
    }

    let order = |v: &Value, f: &str| v[f].as_str().unwrap_or("").to_string();
    edges.sort_by_key(|e| (order(e, "from"), order(e, "to"), order(e, "type")));
    findings.sort_by_key(|f| order(f, "key"));
    json!({
        "format": "pas-graph/1",
        "nodes": nodes.into_values().collect::<Vec<_>>(),
        "edges": edges,
        "findings": findings,
    })
}
Enter fullscreen mode Exit fullscreen mode

Listing 1. A service account key inventory over a Cloud Asset Inventory export, emitting the series graph format.

If you run Security Command Center Premium, some of this already reaches you as findings. Security Health Analytics flags user-managed keys (USER_MANAGED_SERVICE_ACCOUNT_KEY) and keys that haven't been rotated for more than 90 days (SERVICE_ACCOUNT_KEY_NOT_ROTATED) (Google Cloud, n.d.af). Event Threat Detection flags service accounts used from a new geography or with a new user agent (Persistence: New Geography, Persistence: New User Agent) with an organisation-level activation, though both rules run on Admin Activity logs, so a key that only reads BigQuery may not trigger them (Google Cloud, n.d.y). Use them if you have them.

Listing 1 does a different job. It runs on an export without a Premium activation, the export is a file you can keep and compare over time, and its output is the graph the rest of the series uses to show what a key can reach. Part 2 compares that graph with Policy Analyzer and with SCC's attack path simulation (Google Cloud, n.d.x).

Run against the example platform, the first finding is the key this post is about: user-managed, on the dbt job's account, created years ago, with a validBeforeTime of 9999-12-31T23:59:59Z. In our export, that end date marked a key with no expiry. One way to retire it is to move the job to another way of authenticating (control 2), run it on the new credential through at least one full schedule cycle, confirm the old key has stopped authenticating, then disable it. Until you delete the key, re-enabling it is your rollback.

To check when a key last authenticated, Google points to the Key Authentication Events metric (Google Cloud, n.d.e). Activity Analyzer's serviceAccountKeyLastAuthentication gives a last-authentication date per key, but it's in Preview, it's queried per project with up to ten keys per filter, and it may miss very recent events (Google Cloud, n.d.ae). If a key was used at times that don't fit the dbt job's schedule, or the audit logs show it used from somewhere the job doesn't run, look into that before anything else.

Listing 2 exports the organisation's service accounts and keys. Listing 1 expects --content-type=resource, as shown.

Shell

gcloud asset export --organization=ORG_ID --content-type=resource \
  --asset-types=iam.googleapis.com/ServiceAccount,iam.googleapis.com/ServiceAccountKey \
  --output-path=gs://BUCKET/keys.ndjson
Enter fullscreen mode Exit fullscreen mode

Listing 2. Exporting service accounts and their keys for Listing 1. Placeholder values.

A few things trip this up in larger organisations. The caller needs cloudasset.assets.exportResource at the organisation, which the Cloud Asset Viewer role includes (Google Cloud, n.d.h). The destination bucket can't have a retention policy and can't be encrypted with customer-managed Cloud KMS keys, so an organisation that enforces either needs a dedicated, locked-down bucket for this. The export takes time to finish: wait for it before copying the file, and expect a second export to the same path to be rejected until the first one finishes or 15 minutes pass (Google Cloud, n.d.p).

Copy the file down from the bucket, then give Listing 1 the file and a date or RFC 3339 time to measure against, for example python3 inventory.py keys.ndjson 2026-10-15. The Python version needs 3.11 or later. Neither version validates its input. Both expect well-formed records, which is what the export writes, and a partial download can cause a parse error. Treat the export file as sensitive, since it's a map of your accounts and keys.

Each finding also carries expired, the key's project and ancestors so you can route it to an owner, and log_key_name, the key's name in the form audit logs use. To list live, enabled keys oldest first: jq '.findings | map(select((.expired or .disabled) | not)) | sort_by(-.age_days)'.

The field names come from a real export. Top-level fields are snake_case (asset_type, update_time), but fields inside resource.data use the IAM API's camelCase (keyType, validAfterTime). A loader that assumes one style will miss fields.

Our sandbox test turned up three limits worth designing around.

  • Exports lag. An export we took within ten minutes of creating a key and its account had neither. One taken about an hour later had both. That's why Listing 1 reports a key whose account is missing instead of dropping it, and why containment is better confirmed in the IAM API than in an export.
  • Google-managed keys didn't show up. The IAM API listed one for the test account, and Asset Inventory never did. Listing 1 only reports user-managed keys, so it doesn't matter here, but don't treat the export as a complete list of keys.
  • The export doesn't say why a key was disabled. After we disabled ours, the IAM API showed disableReason: SERVICE_ACCOUNT_KEY_DISABLE_REASON_USER_INITIATED, while the inventory record only had disabled: true. If the reason matters, for example after an exposure, read the key from the IAM API, which also has an extendedStatus for exposed or compromised keys.

So Listing 1 is only as complete as the export behind it. It can miss keys created shortly before the export, keys in projects outside the export's scope, and credentials that live outside Google Cloud. Treat a finding as a lead to confirm in the IAM API. A clean run tells you about that export, and doesn't prove there are no keys.

2. Get the job off the key file

Which approach fits depends on where the job runs, and one platform often needs more than one:

  • Scheduled runs inside Google Cloud, such as on Cloud Composer: a service account attached to the runtime. In the dbt-bigquery 1.x adapter, method: oauth picks up application default credentials, so there's no key file to store (dbt Labs, n.d.c). Part 4 covers the Composer side.
  • CI on GitHub Actions: Google documents Workload Identity Federation for deployment pipelines, where a workflow uses its GitHub OIDC token to get short-lived Google Cloud credentials. Google requires an attribute condition that restricts tokens to your GitHub organisation, and recommends the numeric *_id claims over names (Google Cloud, n.d.m). For a production data role, it's worth narrowing the condition to the repository and branch that run dbt. Part 3 covers CI.
  • Engineers' laptops: each engineer's own login, with impersonate_service_account pointing at a development account if you want local runs to use one identity (dbt Labs, n.d.c). Impersonation needs a permission held by roles such as Service Account Token Creator (Google Cloud, n.d.ad), so grant it on the development account only, never on the production job's account.
  • A runner outside Google Cloud whose identity lives in Entra: method: external-oauth-wif, shown in Listing 3. Where that runner is an Azure VM with a managed identity, Google documents federation from the managed identity's token, which avoids a stored secret (Google Cloud, n.d.l). dbt doesn't document that combination, so test it before relying on it.

dbt v2 documents external-oauth-wif with Microsoft Entra ID (dbt Labs, n.d.b), dbt-bigquery's changelog lists a Workload Identity Federation method from the 1.10.0 beta (dbt Labs, n.d.a), and the adapter's source uses the same external-oauth-wif method name (dbt Labs, n.d.c). So dropping the key file may not need a dbt v2 upgrade; check your installed version. Either way, dbt swaps an Entra-issued token for short-lived Google credentials, which needs a workload identity pool and provider configured to trust your Entra application (Google Cloud, n.d.l).

YAML

# Before: dbt 1.x, service account key file
platform:
  target: prod
  outputs:
    prod:
      type: bigquery
      method: service-account
      keyfile: /path/to/dbt-transform-key.json
      project: example-data-platform
      dataset: silver
      location: EU

# After: Workload Identity Federation with Microsoft Entra (dbt v2 syntax; check dbt-bigquery 1.10+ for 1.x)
platform:
  target: external_runner
  outputs:
    external_runner:
      type: bigquery
      method: external-oauth-wif
      database: example-data-platform
      schema: silver
      location: EU
      workload_pool_provider_path: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
      token_endpoint:
        type: entra
        request_url: https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token
        request_data: "grant_type=client_credentials&client_id={{ env_var('ENTRA_CLIENT_ID') }}&client_secret={{ env_var('ENTRA_CLIENT_SECRET') }}&scope=SCOPE/.default"
Enter fullscreen mode Exit fullscreen mode

Listing 3. A dbt profile before (key file) and after (Workload Identity Federation with Entra, in the syntax dbt v2 documents) for a runner outside Google Cloud. Placeholder values throughout.

The old profile uses the older field names project and dataset. The 1.x adapter maps them to database and schema (dbt Labs, n.d.c); on v2, use the new names.

dbt's documented example authenticates to Entra with a client secret, so a stored credential remains. It now lives on the Entra side, and that secret is what you protect, rotate, and inventory. Entra also accepts a certificate or a federated credential instead of a shared secret for this kind of request (Microsoft, n.d.), but dbt doesn't document either for external-oauth-wif, so check your version before relying on them. Keep the secret in the runtime's secret store and out of development targets, and give it an expiry on the Entra side. Listing 1 won't find it, so it needs its own line in your inventory.

What you get in return is Google credentials that are short-lived and issued per run, with the trust sitting in a pool provider whose conditions you control. Restrict who the provider accepts, with an attribute condition that accepts only the dbt job's own Entra identity, or with app role assignments on the Entra application. Google notes that with Azure's default settings, a new application lets every user in the tenant get access tokens for it (Google Cloud, n.d.l).

The federated identity can hold BigQuery roles directly, or the profile can impersonate a service account through the optional service_account_impersonation_url (dbt Labs, n.d.b). If the profile impersonates, use a new service account, not the one with the legacy key. Federation doesn't retire that key, and while the old account stays in the path you may not be able to disable it without stopping the job. Parts 2 and 3 come back to pool conditions and CI identities.

Plan this as a joint change. Entra administrators register the application, own its secret or certificate, and limit its app role assignments. The Google Cloud IAM team creates the pool and provider, writes the attribute condition, and grants roles to the federated identity. The data team updates the profile for each target and runs the cutover.

3. Keep Google's leaked-key response on

iam.serviceAccountKeyExposureResponse decides what happens when Google detects that a service account key has been "exposed publicly". If it isn't set, it defaults to DISABLE_KEY, which "automatically disables any publicly exposed service account key, and creates an entry in the audit log" (Google Cloud, n.d.w). That has been the default for new and existing customers since 16 June 2024 (Google Cloud, n.d.aa; Liu and Martucci, 2024), and Google says it disables keys found in public repositories in partnership with secret-scanning programmes at sites including GitHub and GitLab (Liu and Martucci, 2024).

It only covers public exposure, and Google says it "doesn't guarantee that it will detect leaked keys" (Google Cloud, n.d.e). The key in this post was never public, so the response wouldn't see it. As with a manual disable, tokens already issued from a key it disables may stay valid until they expire (control 6). Check nobody has set the policy to WAIT_FOR_ABUSE, and consider setting DISABLE_KEY explicitly so the behaviour doesn't depend on a default.

4. Make the keys you keep expire

Some integrations still need a key. For those, iam.serviceAccountKeyExpiryHours caps the lifetime of new keys, picked from a fixed list running from one hour to 2,160 hours (Google Cloud, n.d.aa; Google Cloud, n.d.w). It does nothing for keys that already exist, so it sits alongside the inventory and doesn't replace it.

5. Keep BigQuery's access logs past 30 days

BigQuery's Data Access entries are what tell you which tables a credential read. By default they sit in _Default for 30 days (Google Cloud, n.d.i; Google Cloud, n.d.j). Admin Activity entries, such as a key being created or disabled, go to _Required and stay for 400 days, which you can't change (Google Cloud, n.d.i; Google Cloud, n.d.j).

BigQuery's INFORMATION_SCHEMA.JOBS view keeps job history for the past 180 days, including the account that ran each job, its query text, and the tables it referenced (Google Cloud, n.d.t). It only covers jobs run in that project and region, it doesn't record the key or the caller's IP address, and reads through tabledata.list don't create jobs at all. If a key was misused a few months before anyone noticed, you may be able to see that the key authenticated and which queries the account ran, but not where each request came from, which is usually what separates the key holder's activity from the job's own.

If you want to answer what a credential read beyond 30 days, route BigQuery's audit logs, Data Access included, somewhere with retention set for investigations. That can be a user-defined log bucket, with retention from 1 to 3,650 days and an irreversible lock if you want one (Google Cloud, n.d.k), or a BigQuery dataset you can query. It costs money, and the routing becomes one more thing to protect. Part 8 comes back to that, since the detection path is a pipeline too.

For regulated firms, this gap has a compliance side too. In the EU, DORA asks financial entities to identify, classify, and document their ICT assets (Article 8(1)) and to limit logical access to what's required for approved functions (Article 9(4)(c)) (European Union, 2022). The supporting technical standard asks them to set a retention period for their logs and to log access control and identity management events (Article 12) (European Commission, 2024). In the US, New York's cybersecurity regulation asks covered entities to review all user access privileges at least annually and to promptly terminate access following departures (23 NYCRR 500.7(a)(4) and (a)(6)) (Legal Information Institute, n.d.). A key nobody has inventoried, and logs that expire before an investigation starts, may be hard to square with expectations like these. How they apply depends on your entity and your regulator.

BigQuery is unusual in having Data Access logs on by default (Google Cloud, n.d.i). Token requests for other service accounts, such as GenerateAccessToken, are Data Access entries in the Service Account Credentials API's logs, and you turn those on by enabling Data Access logs for the IAM API or for all services (Google Cloud, n.d.ac). Without them, Part 2's question of which accounts a key could act as has no records behind it.

6. If you find a key that's still live

Disabling a key stops it authenticating. In our test, a disabled key and a deleted key failed the same way, with invalid_grant: Invalid JWT Signature., so the error alone won't tell you which happened. Google also notes that disabling a key "does not revoke short-lived credentials that were issued based on the key" (Google Cloud, n.d.n). Google says the way to revoke those early is to disable or delete the service account itself (Google Cloud, n.d.n), which stops every workload using it.

The sequence below is one to adapt to your own runbooks and roles. Keep the order, except where step 4 says otherwise:

  1. Open an incident and an emergency change. Tell the data platform on-call and the owners of downstream data, since disabling credentials will fail scheduled jobs. Confirm the responder holds the roles to disable keys and service accounts, and record each key's current state before changing anything.
  2. Start preserving evidence. Copy the account's existing Data Access entries out of _Default, for example with gcloud logging copy to a Cloud Storage bucket, and add a sink for new entries, since a sink doesn't pick up entries stored before it existed (Google Cloud, n.d.d). Admin Activity entries stay in _Required for 400 days, so they can wait. A copy takes at least an hour, so let it run while you contain. Also export the account's rows from INFORMATION_SCHEMA.JOBS in each project and region it runs jobs in, which needs bigquery.jobs.listAll, since those age out after 180 days.
  3. Confirm in the IAM API that the key is live (gcloud iam service-accounts keys list --iam-account=SA_EMAIL --managed-by=user), and check when it last authenticated (Google Cloud, n.d.e).
  4. Decide whether you can wait, and name in your runbook who makes that call, because it trades pipeline downtime against continued exposure. If step 3 shows use that doesn't fit the job's schedule or runtime, or you can't rule that out, go to step 5 now and accept that the job stays down until step 6 is done. Containment doesn't delete logs, so don't wait for step 2 to finish. Otherwise, do step 6 first, then step 5, with a deadline in days, and alert on any use of the key until it's disabled.
  5. Disable every user-managed key on the account, not just the one you found. On the can't-wait branch, disable the service account too, since disabled keys leave tokens already issued valid until they expire (Google Cloud, n.d.n). On the other branch, once nothing else uses the account, disabling it as well closes the remaining token window.
  6. Move the job to a different principal (control 2). If control 2 isn't already in place, this is planned work through change control, not an incident step. Don't create a new key as a stopgap.
  7. Review what the account did while the key was live: Data Access and Admin Activity entries for that serviceAccountKeyName, tokens it requested for other service accounts, and anything it created or changed that runs as it. Include TableDataRead entries in each project that holds data, since reads by jobs billed elsewhere are logged only there. If it did get tokens for other service accounts, contain those accounts too, because disabling this one doesn't revoke tokens issued to them.
  8. Check who could recreate the exposure: who can create keys for the account, who can impersonate it, and who can grant an exception to the key-creation constraint.
  9. Delete the disabled keys once nothing needs them, the investigation included, and treat deletion as final. For a key exposed in a repository, Google says to delete it in IAM as quickly as possible (Google Cloud, n.d.e).

For a planned cutover, the rollback until step 9 is to re-enable what you disabled. On the can't-wait branch, re-enabling the key or the account hands the holder their access back too, so roll forward to step 6 instead. Disabled keys can only be re-enabled with gcloud or the API, not the console (Google Cloud, n.d.n).

Where Part 2 takes the graph

Listing 1's output has three parts: nodes (principals, credentials, and later resources), edges with a type and an evidence source, and findings. Part 1 adds one edge type, authenticates_as, from a key to its account. Part 2 adds edges for what an account can reach and whom it can impersonate, plus a traversal over them. Later parts add CI identities, DAG buckets, export paths, connector credentials, decrypt rights, and the log sinks themselves.

The graph shows possible reach. Where a deny policy, a Principal Access Boundary policy, or an IAM Condition might block a step, Part 2's traversal will mark the step as possibly blocked and leave the question open. On the edges it has, that means it over-reports, which suits an audit, but it can't report edges its inputs don't contain. Google's Policy Analyzer answers who has what access under allow policies, though Google notes its results don't account for deny policies (Google Cloud, n.d.a). Part 2 compares the two, case by case.

What we tested, and what to check yourself

We ran the key checks in a sandbox project with no client data, in October 2026, under a short, approved exception to the key-creation policy. Besides the token, export-lag, and disable results described above, we confirmed that:

  • the export uses the field names and mixed casing described above, a key with no expiry carries an end time of 9999-12-31T23:59:59Z, and the disabled field only appears once a key is disabled;
  • the two constraint forms report a denied key creation differently. The legacy one returns a PreconditionFailure naming constraints/iam.disableServiceAccountKeyCreation, and the managed one returns an ErrorInfo with reason CUSTOM_ORG_POLICY_VIOLATION.

In your own environment, check the dbt job's actual grants, which may be narrower or wider than dbt's starting point. Check whether anything requested tokens for other service accounts during an exposure, since disabling the key doesn't revoke tokens issued to other accounts. Then check the Entra side of the federation, including where the client secret lives and which identities the provider's condition accepts.

What to alert on

Event Where it's seen
A service account key created or uploaded (google.iam.admin.v1.CreateServiceAccountKey, UploadServiceAccountKey) Admin Activity (Google Cloud, n.d.s)
A key disabled, enabled, or deleted (DisableServiceAccountKey, EnableServiceAccountKey, DeleteServiceAccountKey) Admin Activity (Google Cloud, n.d.s)
A key disabled by the exposure response: DisableServiceAccountKey by gcp-compromised-key-response@system.gserviceaccount.com Admin Activity (Google Cloud, n.d.aa; Google Cloud, n.d.s)
A change to an organisation policy on the key constraints, including a project-level exception or the exposure response set to WAIT_FOR_ABUSE (google.cloud.orgpolicy.v2.OrgPolicy.CreatePolicy, UpdatePolicy, DeletePolicy, and the older cloudresourcemanager.v1 setOrgPolicy and clearOrgPolicy on organisations, folders, and projects) Admin Activity (Google Cloud, n.d.v; Google Cloud, n.d.z)
Before a job moves off its key: a call whose protoPayload.authenticationInfo.serviceAccountKeyName matches a finding's log_key_name, from a caller IP or user agent the dbt runtime doesn't use. Build the baseline from the runtime's actual callerIp values, and don't treat gce-internal-ip as safe, since a VM outside your organisation can show it too. The user agent is supplied by the client, so treat it as a hint Data Access and Admin Activity, where the service logs the key name (Google Cloud, n.d.o; Google Cloud, n.d.c)
After a job moves off its key: any authentication with that key The Key Authentication Events metric (iam.googleapis.com/service_account/key/authn_events_count, by key_id), alerting on any count; data can take up to three hours to appear (Google Cloud, n.d.e; Google Cloud, n.d.r)
A key's account requesting tokens for another service account (GenerateAccessToken and related methods) Service Account Credentials Data Access logs, once enabled (Google Cloud, n.d.ac)
A BigQuery extract job to a bucket that isn't on your list BigQuery Data Access: the job's own entries when it runs in your project, and TableDataRead entries on your datasets for reads by jobs run elsewhere (Google Cloud, n.d.f); for buckets outside your organisation, Event Threat Detection's BigQuery data extraction rule where you run it (Google Cloud, n.d.y); Part 5 covers exports in detail
A user-managed key with no expiry, or older than your rotation limit Listing 1, run on a schedule

The ATT&CK mapping

The mapping uses MITRE ATT&CK v20.0, released on 13 October 2026, and only covers the techniques in this post, plus the key creation the alerts above watch for.

Step Technique Tactic
The key file on a share or in a backup T1552.001 Unsecured Credentials: Credentials In Files (MITRE, 2026b) Credential Access
A new key on a pipeline account T1098.001 Account Manipulation: Additional Cloud Credentials (MITRE, 2026d) Persistence and Privilege Escalation
Acting as the service account T1078.004 Valid Accounts: Cloud Accounts (MITRE, 2026a) Initial Access, Persistence, Privilege Escalation, and Stealth
Query jobs reading silver and gold T1213.006 Data from Information Repositories: Databases (MITRE, 2026c) Collection
An export to a bucket the holder controls T1537 Transfer Data to Cloud Account (MITRE, 2025), our reading for BigQuery Exfiltration

This week

Run the inventory, then take these questions to your platform team:

  1. How many user-managed service account keys exist across the organisation, and how many never expire?
  2. Which of them sit on accounts that run data pipelines, and when did each last authenticate?
  3. Do we keep BigQuery Data Access logs for longer than 30 days, and where?
  4. Is BigQuery inside a VPC Service Controls perimeter, and would it stop an export to a bucket outside our organisation?
  5. If we move to Workload Identity Federation with Entra, where does the client secret live, who rotates it, and which identities does the provider accept?
  6. Is the key exposure response explicitly set to DISABLE_KEY?

Part 2 looks at the account itself: what the dbt job's account can reach beyond its own datasets, which other accounts it can act as, and how to work that out from the same export.

References

dbt Labs, n.d.a. CHANGELOG, dbt-bigquery adapter. GitHub. Available at: https://github.com/dbt-labs/dbt-adapters/blob/main/dbt-bigquery/CHANGELOG.md [Accessed 22 October 2026].

dbt Labs, n.d.b. Connect BigQuery to dbt v2. dbt Developer Hub. Available at: https://docs.getdbt.com/docs/local/connect-data-platform/bigquery-setup [Accessed 15 October 2026].

dbt Labs, n.d.c. credentials.py, dbt-bigquery adapter. GitHub. Available at: https://github.com/dbt-labs/dbt-adapters/blob/main/dbt-bigquery/src/dbt/adapters/bigquery/credentials.py [Accessed 15 October 2026].

European Commission, 2024. Commission Delegated Regulation (EU) 2024/1774 of 13 March 2024 supplementing Regulation (EU) 2022/2554 of the European Parliament and of the Council with regard to regulatory technical standards specifying ICT risk management tools, methods, processes, and policies and the simplified ICT risk management framework. Official Journal of the European Union. Available at: https://eur-lex.europa.eu/eli/reg_del/2024/1774/oj [Accessed 22 October 2026].

European Union, 2022. Regulation (EU) 2022/2554 of the European Parliament and of the Council of 14 December 2022 on digital operational resilience for the financial sector and amending Regulations (EC) No 1060/2009, (EU) No 648/2012, (EU) No 600/2014, (EU) No 909/2014 and (EU) 2016/1011. Official Journal of the European Union, L 333. Available at: https://eur-lex.europa.eu/eli/reg/2022/2554/oj [Accessed 22 October 2026].

Google Cloud, n.d.a. Analyze allow policies. Google Cloud Documentation. Available at: https://docs.cloud.google.com/policy-intelligence/docs/analyze-iam-policies [Accessed 15 October 2026].

Google Cloud, n.d.b. Asset types. Google Cloud Documentation. Available at: https://docs.cloud.google.com/asset-inventory/docs/asset-types [Accessed 15 October 2026].

Google Cloud, n.d.c. AuditLog. Google Cloud Documentation. Available at: https://docs.cloud.google.com/logging/docs/reference/audit/auditlog/rest/Shared.Types/AuditLog [Accessed 22 October 2026].

Google Cloud, n.d.d. Batch and route log entries retroactively. Google Cloud Documentation. Available at: https://docs.cloud.google.com/logging/docs/routing/copy-logs [Accessed 22 October 2026].

Google Cloud, n.d.e. Best practices for managing service account keys. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys [Accessed 15 October 2026].

Google Cloud, n.d.f. BigQuery audit logs overview. Google Cloud Documentation. Available at: https://docs.cloud.google.com/bigquery/docs/reference/auditlogs [Accessed 15 October 2026].

Google Cloud, n.d.g. BigQuery IAM roles and permissions. Google Cloud Documentation. Available at: https://docs.cloud.google.com/bigquery/docs/access-control [Accessed 15 October 2026].

Google Cloud, n.d.h. Cloud Asset Inventory roles and permissions. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/roles-permissions/cloudasset [Accessed 22 October 2026].

Google Cloud, n.d.i. Cloud Audit Logs overview. Google Cloud Documentation. Available at: https://docs.cloud.google.com/logging/docs/audit [Accessed 11 October 2026].

Google Cloud, n.d.j. Cloud Logging quotas and limits. Google Cloud Documentation. Available at: https://docs.cloud.google.com/logging/quotas [Accessed 11 October 2026].

Google Cloud, n.d.k. Configure log buckets. Google Cloud Documentation. Available at: https://docs.cloud.google.com/logging/docs/buckets [Accessed 11 October 2026].

Google Cloud, n.d.l. Configure Workload Identity Federation with AWS or Azure VMs. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds [Accessed 15 October 2026].

Google Cloud, n.d.m. Configure Workload Identity Federation with deployment pipelines. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines [Accessed 22 October 2026].

Google Cloud, n.d.n. Disable and enable service account keys. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/keys-disable-enable [Accessed 15 October 2026].

Google Cloud, n.d.o. Example logs for service accounts. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/audit-logging/examples-service-accounts [Accessed 15 October 2026].

Google Cloud, n.d.p. Export asset metadata to Cloud Storage. Google Cloud Documentation. Available at: https://docs.cloud.google.com/asset-inventory/docs/export-cloud-storage [Accessed 11 October 2026].

Google Cloud, n.d.q. Export table data to Cloud Storage. Google Cloud Documentation. Available at: https://docs.cloud.google.com/bigquery/docs/exporting-data [Accessed 15 October 2026].

Google Cloud, n.d.r. Google Cloud metrics: I through O. Google Cloud Documentation. Available at: https://docs.cloud.google.com/monitoring/api/metrics_gcp_i_o [Accessed 22 October 2026].

Google Cloud, n.d.s. IAM audit logging. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/audit-logging [Accessed 15 October 2026].

Google Cloud, n.d.t. JOBS view. Google Cloud Documentation. Available at: https://docs.cloud.google.com/bigquery/docs/information-schema-jobs [Accessed 22 October 2026].

Google Cloud, n.d.u. Method: tabledata.list. Google Cloud Documentation. Available at: https://docs.cloud.google.com/bigquery/docs/reference/rest/v2/tabledata/list [Accessed 22 October 2026].

Google Cloud, n.d.v. Organization Policy audit logging. Google Cloud Documentation. Available at: https://docs.cloud.google.com/organization-policy/audit-logging [Accessed 22 October 2026].

Google Cloud, n.d.w. Organization policy constraints. Google Cloud Documentation. Available at: https://docs.cloud.google.com/organization-policy/reference/org-policy-constraints [Accessed 15 October 2026].

Google Cloud, n.d.x. Overview of attack exposure scores and attack paths. Google Cloud Documentation. Available at: https://docs.cloud.google.com/security-command-center/docs/attack-exposure-learn [Accessed 22 October 2026].

Google Cloud, n.d.y. Overview of Event Threat Detection. Google Cloud Documentation. Available at: https://docs.cloud.google.com/security-command-center/docs/concepts-event-threat-detection-overview [Accessed 22 October 2026].

Google Cloud, n.d.z. Resource Manager audit logging. Google Cloud Documentation. Available at: https://docs.cloud.google.com/resource-manager/docs/audit-logging [Accessed 22 October 2026].

Google Cloud, n.d.aa. Restricting service account usage. Google Cloud Documentation. Available at: https://docs.cloud.google.com/organization-policy/restrict-service-accounts [Accessed 15 October 2026].

Google Cloud, n.d.ab. Service account credentials. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/service-account-creds [Accessed 15 October 2026].

Google Cloud, n.d.ac. Service Account Credentials audit logging. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/audit-logging/audit-logging-iamcreds [Accessed 15 October 2026].

Google Cloud, n.d.ad. Service account impersonation. Google Cloud Documentation. Available at: https://docs.cloud.google.com/iam/docs/service-account-impersonation [Accessed 22 October 2026].

Google Cloud, n.d.ae. View recent usage for service accounts and keys. Google Cloud Documentation. Available at: https://docs.cloud.google.com/policy-intelligence/docs/activity-analyzer-service-account-authentication [Accessed 22 October 2026].

Google Cloud, n.d.af. Vulnerability findings. Google Cloud Documentation. Available at: https://docs.cloud.google.com/security-command-center/docs/concepts-vulnerabilities-findings [Accessed 22 October 2026].

Legal Information Institute, n.d. N.Y. Comp. Codes R. & Regs. Tit. 23 ยง 500.7, Access privileges and management. Cornell Law School. Available at: https://www.law.cornell.edu/regulations/new-york/23-NYCRR-500.7 [Accessed 22 October 2026].

Liu, S. and Martucci, A., 2024. Automatically disabling leaked service account keys: What you need to know. Google Cloud Blog, 15 May. Available at: https://cloud.google.com/blog/products/identity-security/automatically-disabling-leaked-service-account-keys-what-you-need-to-know [Accessed 11 October 2026].

Microsoft, n.d. Microsoft identity platform and the OAuth 2.0 client credentials flow. Microsoft Learn. Available at: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow [Accessed 15 October 2026].

MITRE, 2025. Transfer Data to Cloud Account (T1537). MITRE ATT&CK. Available at: https://attack.mitre.org/techniques/T1537/ [Accessed 15 October 2026].

MITRE, 2026a. Valid Accounts: Cloud Accounts (T1078.004). MITRE ATT&CK. Available at: https://attack.mitre.org/techniques/T1078/004/ [Accessed 15 October 2026].

MITRE, 2026b. Unsecured Credentials: Credentials In Files (T1552.001). MITRE ATT&CK. Available at: https://attack.mitre.org/techniques/T1552/001/ [Accessed 15 October 2026].

MITRE, 2026c. Data from Information Repositories: Databases (T1213.006). MITRE ATT&CK. Available at: https://attack.mitre.org/techniques/T1213/006/ [Accessed 15 October 2026].

MITRE, 2026d. Account Manipulation: Additional Cloud Credentials (T1098.001). MITRE ATT&CK. Available at: https://attack.mitre.org/techniques/T1098/001/ [Accessed 22 October 2026].

Top comments (0)