IAM answers “is this identity allowed to call this API?” It does not answer “even with valid credentials, is this request allowed to leave with the data?” That gap is the entire exfiltration story on GCP: a leaked service account key, an over-scoped OAuth token, or a compromised laptop can read a Cloud Storage bucket or run a BigQuery extract from anywhere on the internet, and IAM will happily authorize it because the credential is valid. VPC Service Controls (VPC-SC) closes that gap by drawing a network-and-identity perimeter around the managed-API control plane itself. This guide builds a perimeter from the threat model up, layers Access Context Manager on top, opens controlled holes with ingress/egress rules, and — most importantly — rolls the whole thing out in dry-run so you find out what breaks before your users do.
1. The threat model VPC-SC actually addresses
Be precise about scope, because VPC-SC is frequently mis-sold. It protects against data exfiltration over the Google API surface — storage.googleapis.com, bigquery.googleapis.com, secretmanager.googleapis.com, and roughly 100 other managed services. The concrete attacks it defeats:
- Stolen credentials used off-network. An attacker exfiltrates a service account key, then runs
gsutil cp gs://prod-bucket/* .from their own machine. IAM says yes; the perimeter says no, because the request originates outside the perimeter. - Data exfiltration to an attacker-controlled project. An insider with
storage.objects.getcopies objects fromgs://your-prod-buckettogs://attacker-bucketin a personal project. Egress rules block the destination project. - IAM misconfiguration blast radius. Someone fat-fingers
allUsersonto a bucket. The perimeter still blocks reads from outside it, turning a critical leak into a non-event.
What VPC-SC does not do: it is not a firewall (that is hierarchical firewall policies and Cloud NAT), it is not IAM (you still need least-privilege bindings), and it does nothing for data leaving via a GCE VM’s own NIC to the public internet. It is one layer. Treat it as the control-plane perimeter that sits alongside network controls, not instead of them.
The mental model: IAM is “who you are and what role you hold.” VPC-SC is “from where, and to where, the request and its data are allowed to flow.” A request must satisfy both. They are AND-ed, never OR-ed.
2. Perimeters, restricted services, and the restricted VIP
A service perimeter is a boundary around a set of projects (more precisely, project numbers; perimeters can also protect VPC networks directly) within which a chosen list of restricted services can only be reached by callers inside the perimeter. Everything lives under an access policy bound to your organization.
First, find or create the org-scoped access policy. There is normally exactly one.
ORG_ID=123456789012
# Is there already an org access policy?
gcloud access-context-manager policies list --organization="$ORG_ID"
# If empty, create one (scopeless == org-wide)
gcloud access-context-manager policies create \
--organization="$ORG_ID" \
--title="org-access-policy"
POLICY=$(gcloud access-context-manager policies list \
--organization="$ORG_ID" --format="value(name)")
Now create a perimeter protecting two projects, restricting Storage, BigQuery, and Secret Manager:
gcloud access-context-manager perimeters create prod_data_perimeter \
--policy="$POLICY" \
--title="prod-data-perimeter" \
--resources="projects/111111111111,projects/222222222222" \
--restricted-services="storage.googleapis.com,bigquery.googleapis.com,secretmanager.googleapis.com" \
--perimeter-type=regular
Two non-obvious but critical points:
Resources are project numbers, not IDs. projects/my-prod-app will be silently wrong in some tooling; always resolve with gcloud projects describe my-prod-app --format='value(projectNumber)'.
The restricted VIP is mandatory for it to mean anything. When a service is restricted, in-perimeter callers must reach it through the restricted Virtual IP, restricted.googleapis.com (199.36.153.4/30), not the public endpoint. This requires Private Google Access plus a DNS override and a route. The DNS response CNAMEs the public hostnames to the restricted VIP:
# Private DNS zone so *.googleapis.com resolves to the restricted VIP
gcloud dns managed-zones create restricted-googleapis \
--project=my-host-project \
--visibility=private \
--networks=projects/my-host-project/global/networks/prod-vpc \
--dns-name=googleapis.com. \
--description="Force Google APIs onto the restricted VIP"
gcloud dns record-sets create googleapis.com. \
--project=my-host-project --zone=restricted-googleapis \
--type=A --ttl=300 --rrdatas="199.36.153.4,199.36.153.5,199.36.153.6,199.36.153.7"
gcloud dns record-sets create '*.googleapis.com.' \
--project=my-host-project --zone=restricted-googleapis \
--type=CNAME --ttl=300 --rrdatas="googleapis.com."
Then add a route to 199.36.153.4/30 via the default internet gateway, and ensure subnets have --enable-private-ip-google-access. Without the VIP wiring, requests still hit the public endpoint and the perimeter looks like it “isn’t working” — when in fact the traffic never entered it.
3. Access Context Manager: access levels by IP, identity, and device
Restricting services to in-perimeter callers is binary. Access levels add nuance: they are reusable predicates — “the corporate egress IPs,” “a managed device,” “a specific identity” — that you reference from ingress rules and from the perimeter’s --access-levels to grant conditional entry to human and external callers.
The cleanest way to author non-trivial levels is a YAML spec applied with replace-all. A basic level pinning to corporate egress CIDRs:
# corp-network.yaml
- ipSubnetworks:
- 203.0.113.0/24
- 198.51.100.0/24
gcloud access-context-manager levels create corp_network \
--policy="$POLICY" \
--title="corp-network" \
--basic-level-spec=corp-network.yaml \
--combine-function=AND
Conditions within a single block are AND-ed; multiple blocks (list entries) are OR-ed when --combine-function=OR. To require the request come from corp IPs and be made by a member of an approved group, combine an IP condition with members:
# corp-and-trusted-identity.yaml
- ipSubnetworks:
- 203.0.113.0/24
members:
- user:breakglass@example.com
- serviceAccount:etl-runner@my-prod-app.iam.gserviceaccount.com
For device trust (managed, encrypted, screen-locked, or — with Endpoint Verification — corporate-owned), use devicePolicy:
# managed-device.yaml
- devicePolicy:
requireScreenlock: true
requireCorpOwned: true
allowedEncryptionStatuses:
- ENCRYPTED
osConstraints:
- osType: DESKTOP_MAC
minimumVersion: "14.0.0"
- osType: DESKTOP_CHROME_OS
Access levels are an allow-grant, not a restriction. They never tighten a perimeter; they open it to callers who would otherwise be blocked. A perimeter with no access levels and no ingress rules is maximally closed — which is exactly where you want enforcement to start.
4. Ingress and egress rules for controlled cross-perimeter access
Real systems are not hermetic. Your CI in a tooling project deploys to prod; a partner service reads one bucket; an analytics job in another perimeter queries BigQuery. Ingress/egress rules are the surgical holes — far better than the legacy “perimeter bridge” because they are directional, scoped to identity and service and method, and self-documenting.
- Ingress rules govern requests into the perimeter from outside it.
- Egress rules govern requests out of the perimeter to resources elsewhere.
Each rule has a from (source: identities and/or sources like access levels or other projects) and a to (destination: resources and the operations/services permitted). Author them as YAML and apply with --set-policy/update.
Allow a CI service account living outside the perimeter to push objects into a perimeter bucket:
# ingress-ci.yaml
- ingressFrom:
identities:
- serviceAccount:deployer@tooling-project.iam.gserviceaccount.com
sources:
- accessLevel: "*"
ingressTo:
operations:
- serviceName: storage.googleapis.com
methodSelectors:
- method: google.storage.objects.create
- method: google.storage.objects.get
resources:
- "*"
gcloud access-context-manager perimeters update prod_data_perimeter \
--policy="$POLICY" \
--set-ingress-policies=ingress-ci.yaml
Allow an in-perimeter ETL job to read from a specific bucket in a partner project (egress):
# egress-partner-read.yaml
- egressFrom:
identities:
- serviceAccount:etl-runner@my-prod-app.iam.gserviceaccount.com
egressTo:
operations:
- serviceName: storage.googleapis.com
methodSelectors:
- method: google.storage.objects.get
- method: google.storage.objects.list
resources:
- projects/333333333333
gcloud access-context-manager perimeters update prod_data_perimeter \
--policy="$POLICY" \
--set-egress-policies=egress-partner-read.yaml
Two rules of thumb. Scope resources to specific project numbers, not "*", on egress — an egress rule with resources: ["*"] and a wildcard service is functionally a hole the size of the entire managed-API surface. And prefer methodSelectors over * for method: objects.create + objects.get is a deploy path; * includes setIamPolicy and bucket deletion.
5. Dry-run mode: rolling out without breaking workloads
This is the step that separates a successful VPC-SC rollout from a Sev-1. Never enforce a perimeter blind. A perimeter (and every individual ingress/egress rule) has a dry-run configuration that evaluates exactly as enforcement would and logs every violation it would have caused — without blocking anything.
Create the perimeter in dry-run by attaching the spec to the spec config and marking it dry-run:
# Promote an existing enforced perimeter's config into dry-run for editing,
# or author dry-run directly:
gcloud access-context-manager perimeters dry-run create prod_data_perimeter \
--policy="$POLICY" \
--resources="projects/111111111111,projects/222222222222" \
--restricted-services="storage.googleapis.com,bigquery.googleapis.com,secretmanager.googleapis.com"
You can iterate on the dry-run spec (add restricted services, tweak ingress/egress) independently of what is enforced:
gcloud access-context-manager perimeters dry-run update prod_data_perimeter \
--policy="$POLICY" \
--add-restricted-services="aiplatform.googleapis.com"
Let it bake for at least one full business cycle — a week covers weekly batch jobs, monthly close jobs will surprise you. Mine the dry-run violations (Step 8), fix each one with a rule or by moving a resource inside the perimeter, and only when violations go quiet do you promote:
# Apply the dry-run config as the enforced config
gcloud access-context-manager perimeters dry-run enforce prod_data_perimeter \
--policy="$POLICY"
Dry-run logs appear under method
*DryRun*in audit logs withvpcServiceControlsUniqueIdentifier. A request that would be blocked succeeds and emits a violation entry; a request that succeeds under both specs emits nothing. Zero dry-run violations over a representative window is your green light — not “it looked fine for an hour.”
6. Perimeter bridges and multi-project, multi-perimeter designs
For most cross-perimeter access, reach for ingress/egress rules first. Perimeter bridges remain useful for one pattern: a set of projects that must freely share a restricted service among themselves while each also belonging to its own regular perimeter. A bridge is a perimeter of type bridge whose member projects can call each other’s restricted services without rules.
gcloud access-context-manager perimeters create shared_analytics_bridge \
--policy="$POLICY" \
--title="shared-analytics-bridge" \
--perimeter-type=bridge \
--resources="projects/111111111111,projects/444444444444"
A project can sit in exactly one regular perimeter but in multiple bridges. Architecturally, the patterns that scale:
| Pattern | When | Trade-off |
|---|---|---|
| One large perimeter per environment (all of prod) | Few teams, uniform trust | Coarse blast radius; one bad rule is org-wide |
| Perimeter per data domain (payments, PII, analytics) | Strong domain isolation needs | More rules; clearer ownership and audit |
| Regular perimeters + bridges for shared assets | Teams isolated but share a data lake | Bridges are coarse — they share all restricted services |
Lean toward domain perimeters with explicit egress rules over bridges. Bridges are blunt; an ingress/egress rule says exactly “this identity, this method, this destination,” which is what an auditor (and future-you) wants to read.
7. Common breakages: BigQuery, Cloud Build, and service agents
These are the ones that will actually page you.
- BigQuery cross-project query and copy. A query reading a dataset in a project outside the perimeter, or
EXPORT DATAto an external bucket, is an egress event. Scheduled queries and BI Engine run as service agents — they need ingress/egress allowances, not just human identities. - Cloud Build. The Cloud Build service agent and the per-build worker pull source, push images to Artifact Registry, and read secrets. Default (non-private) worker pools run outside your perimeter, so builds that touch restricted services fail. Fix: use private worker pools peered into a perimeter VPC, or add ingress rules for
cloudbuildandartifactregistryoperations. The default@cloudbuild.gserviceaccount.comagent often needs explicit ingress. - Service agents in general. Dataflow, Composer, Vertex AI, Pub/Sub, and Dataproc all act as
service-<PROJECT_NUMBER>@...gserviceaccount.comagents. When the perimeter blocks them, the symptom is an opaque “permission denied” that IAM swears is granted — because it is granted; VPC-SC is the layer denying it. Always check violation logs before re-granting IAM.
# ingress for Cloud Build + Artifact Registry from a tooling perimeter/project
- ingressFrom:
identities:
- serviceAccount:service-111111111111@gcp-sa-cloudbuild.iam.gserviceaccount.com
sources:
- accessLevel: "*"
ingressTo:
operations:
- serviceName: artifactregistry.googleapis.com
methodSelectors:
- method: "*"
- serviceName: storage.googleapis.com
methodSelectors:
- method: google.storage.objects.get
resources:
- "*"
A perimeter does not exempt Google’s own managed services. If a first-party service agent legitimately needs in, it goes in the rules like any other identity. There is no implicit allowlist for “Google services.”
8. Reading VPC-SC violation logs and iterating toward enforcement
Every block (real or dry-run) writes a Cloud Audit Log entry with a metadata block of type VpcServiceControlAuditMetadata. This is your single source of truth — read it, do not guess. Query in Logs Explorer with this filter:
logName=~"cloudaudit.googleapis.com%2Fpolicy"
protoPayload.metadata.@type="type.googleapis.com/google.cloud.audit.VpcServiceControlAuditMetadata"
For just the violations a dry-run config would have caused (so you can clear them before enforcing):
logName=~"cloudaudit.googleapis.com%2Fpolicy"
protoPayload.metadata.violationReason!=""
protoPayload.metadata.dryRun=true
The fields that tell you exactly what to do:
| Field | What it tells you |
|---|---|
protoPayload.metadata.violationReason |
e.g. NO_MATCHING_ACCESS_LEVEL, RESOURCES_NOT_IN_SAME_SERVICE_PERIMETER, SERVICE_NOT_ALLOWED_FROM_VPC |
protoPayload.authenticationInfo.principalEmail |
which identity to put in an ingress/egress rule |
protoPayload.metadata.ingressViolations / egressViolations |
the source/target project and service — your rule’s from/to |
protoPayload.requestMetadata.callerIp |
source IP — feed it into an access level if it is a corp egress |
protoPayload.serviceName / methodName |
the exact serviceName + method for methodSelectors |
The loop is mechanical and should be boring: read a violation, decide whether it is legitimate (write a tightly-scoped rule) or an attack (you just caught one), update the dry-run config, wait, repeat until the dry-run violation count for legitimate traffic is zero. Then dry-run enforce. Wire a log-based metric on violationReason!="" with dryRun=false so that post-enforcement violations page you — those are either a new legitimate workload that needs a rule, or someone trying to walk out with your data.
Enterprise scenario
A fintech platform team ran a regulated data lake: customer PII in BigQuery and Cloud Storage across two prod projects, with a strict control that “production data must never be readable from outside the corporate network or copied to a non-prod project.” IAM was already least-privilege, but an internal red-team exercise lifted a Dataflow worker’s service account token from a debug log and demonstrated that, with that token, they could bq extract a PII table to a personal GCS bucket from a coffee-shop laptop. IAM authorized it end to end. That was the finding that funded VPC-SC.
The constraint that made rollout hard: a nightly Cloud Composer (Airflow) DAG ran ~40 BigQuery jobs, several of which read a reference dataset hosted in a separate “shared-ref” project, and the reporting team’s Looker instance queried BigQuery from a fixed NAT egress IP block. A naive enforced perimeter would have black-holed the entire nightly close and every dashboard the next morning.
They ran the perimeter in dry-run for three weeks — long enough to capture the monthly close. Mining VpcServiceControlAuditMetadata dry-run violations surfaced exactly three legitimate gaps: the Composer service agent reading cross-project, the BigQuery copy into the reference project, and Looker’s IP block. Each became a tightly-scoped rule rather than a broad opening. The Looker access level pinned to the NAT CIDRs:
# looker-egress-ip.yaml
- ipSubnetworks:
- 203.0.113.16/28
The Composer egress to the reference project, scoped to read methods only:
# composer-ref-read.yaml
- egressFrom:
identities:
- serviceAccount:service-111111111111@cloudcomposer-accounts.iam.gserviceaccount.com
egressTo:
operations:
- serviceName: bigquery.googleapis.com
methodSelectors:
- method: "google.cloud.bigquery.v2.JobService.InsertJob"
resources:
- projects/555555555555
After two consecutive nights of zero dry-run violations across the full DAG, they ran dry-run enforce. The red-team’s stolen-token attack now failed with NO_MATCHING_ACCESS_LEVEL from the external IP — the credential was still valid, but the data could not leave. Total user-facing impact at enforcement: zero. The cost was three weeks of patience and a habit of reading violation logs instead of guessing.
Verify
Confirm the perimeter behaves before and after enforcement:
# 1. Perimeter config and its enforced vs dry-run specs
gcloud access-context-manager perimeters describe prod_data_perimeter \
--policy="$POLICY"
# 2. From an in-perimeter VM, restricted API must resolve to the VIP
dig storage.googleapis.com +short # expect 199.36.153.x
gsutil ls gs://prod-bucket # succeeds in-perimeter
# 3. From OUTSIDE the perimeter, with valid creds, it must be denied
# (run on a non-perimeter host using the same SA)
gsutil ls gs://prod-bucket # expect: 403, request violates VPC-SC
# 4. List active access levels and rules
gcloud access-context-manager levels list --policy="$POLICY"
gcloud access-context-manager perimeters describe prod_data_perimeter \
--policy="$POLICY" --format="yaml(status.ingressPolicies,status.egressPolicies)"
In Logs Explorer, after enforcement, confirm legitimate workloads emit no violationReason while a deliberate out-of-perimeter call does. That asymmetry is proof the perimeter is real and correctly scoped.