Serverless Security on GCP: IAM, VPC Connector, and Secret Manager
Serverless doesn't mean security-free. Cloud Run and Cloud Functions have an IAM model, network configuration, and secret handling that require explicit attention. This guide secures serverless workloads end-to-end: identity, network isolation, secret injection, and audit logging.
The "serverless" promise often gets interpreted as "no infrastructure to secure." That's wrong. When you deploy a Cloud Run service or Cloud Function, you're making a series of security decisions — even if you make them by accepting defaults. The default Cloud Run service is publicly accessible, runs with the Compute Engine default service account (which has broad project-level permissions), fetches secrets through environment variables in plaintext, and communicates with other services over the public internet.
That default configuration might be acceptable for a demo. It's not acceptable for production workloads handling customer data.
This guide works through the four security layers that matter for serverless GCP workloads: IAM authentication (who can call your service), network isolation (what your service can reach and from where), secret management (how your service gets credentials), and audit logging (what you can see after the fact).
Layer 1: IAM Authentication for Service-to-Service Communication
By default, Cloud Run services can be configured with --allow-unauthenticated (public internet access) or --no-allow-unauthenticated (require authentication). Never use --allow-unauthenticated for internal services.
Authenticating Between Cloud Run Services
Service A calls Service B. Service A needs to prove its identity to Service B using an OIDC token:
# service_a/client.py — fetching an ID token for Service B
import google.auth.transport.requests
import google.oauth2.id_token
import requests
def call_service_b(endpoint: str, payload: dict) -> dict:
"""Call Service B with an authenticated request."""
auth_req = google.auth.transport.requests.Request()
# Fetch an ID token for Service B's URL (the audience)
id_token = google.oauth2.id_token.fetch_id_token(auth_req, endpoint)
response = requests.post(
endpoint,
json=payload,
headers={"Authorization": f"Bearer {id_token}"},
timeout=30,
)
response.raise_for_status()
return response.json()
Grant Service A's service account permission to invoke Service B:
# Service A runs as: service-a-sa@my-project.iam.gserviceaccount.com
# Grant it invoker role on Service B
gcloud run services add-iam-policy-binding service-b --region=europe-west4 --member=serviceAccount:service-a-sa@my-project.iam.gserviceaccount.com --role=roles/run.invoker
Minimum Permissions for Cloud Run Service Accounts
Create dedicated service accounts for each service with only the permissions it needs:
# Create a dedicated service account for your service
gcloud iam service-accounts create payment-service-sa --display-name="Payment Service"
# Grant only what's needed
# Example: BigQuery read access + Secret Manager access
gcloud projects add-iam-policy-binding my-project --member=serviceAccount:payment-service-sa@my-project.iam.gserviceaccount.com --role=roles/bigquery.dataViewer
gcloud projects add-iam-policy-binding my-project --member=serviceAccount:payment-service-sa@my-project.iam.gserviceaccount.com --role=roles/secretmanager.secretAccessor
# Deploy Cloud Run service with the dedicated SA
gcloud run deploy payment-service --image=europe-west4-docker.pkg.dev/my-project/repo/payment:latest --region=europe-west4 --service-account=payment-service-sa@my-project.iam.gserviceaccount.com --no-allow-unauthenticated
Never use the Compute Engine default service account (-compute@developer.gserviceaccount.com) for production services — it has Editor access on the project by default.
Authenticating External Requests with Google Identity
For services that external users call (after your frontend has authenticated them with Google), validate the Google ID token server-side:
from google.oauth2 import id_token
from google.auth.transport import requests as google_requests
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
ALLOWED_CLIENT_IDS = ["your-google-oauth-client-id.apps.googleusercontent.com"]
def require_google_auth(f):
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
abort(401, "Missing authorization header")
token = auth_header[7:]
try:
id_info = id_token.verify_oauth2_token(
token,
google_requests.Request(),
audience=None, # Validate against allowed client IDs below
)
if id_info["aud"] not in ALLOWED_CLIENT_IDS:
abort(401, "Invalid token audience")
request.user_email = id_info["email"]
except Exception as e:
abort(401, f"Invalid token: {e}")
return f(*args, **kwargs)
return decorated
@app.route("/api/data")
@require_google_auth
def get_data():
return jsonify({"user": request.user_email, "data": fetch_user_data(request.user_email)})
Layer 2: VPC Connector for Private Network Access
By default, Cloud Run services access the internet through Google's public infrastructure. To reach resources on your VPC (Cloud SQL, Memorystore Redis, internal load balancers), you need a VPC connector.
# Create a VPC Access Connector in the same region as your Cloud Run service
gcloud compute networks vpc-access connectors create my-vpc-connector --region=europe-west4 --subnet=connector-subnet --subnet-project=my-project --min-instances=2 --max-instances=10 --machine-type=e2-micro
# Configure Cloud Run to use the connector
gcloud run services update my-service --region=europe-west4 --vpc-connector=my-vpc-connector --vpc-egress=private-ranges-only # Only route RFC1918 traffic through VPC
The --vpc-egress flag controls routing:
private-ranges-only: Private IP traffic (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) goes through VPC; internet traffic goes directlyall-traffic: All traffic routes through VPC (more secure, needed for firewall-controlled egress)
Configuring Cloud SQL Private IP Access
# Cloud SQL instance must have private IP enabled
gcloud sql instances patch my-db --network=projects/my-project/global/networks/my-vpc --no-assign-ip
# Connect from Cloud Run using the private IP directly
# No need for Cloud SQL Auth Proxy when using private IP + VPC connector
DATABASE_URL="postgresql://user:password@10.x.x.x:5432/mydb"
gcloud run services update my-service --region=europe-west4 --vpc-connector=my-vpc-connector --set-env-vars=DATABASE_URL=$DATABASE_URL
Prefer private IP + VPC connector over Cloud SQL Auth Proxy for production — it has lower latency and fewer moving parts.
Layer 3: Secret Manager for Credentials
Never put secrets in environment variables, Dockerfile ENV instructions, or container images. Secrets in environment variables are visible in the Cloud Run console, in gcloud run services describe output, and in Cloud Audit Logs for anyone with read access.
Creating and Mounting Secrets
# Create a secret in Secret Manager
echo -n "my-database-password" | gcloud secrets create db-password --data-file=- --replication-policy=automatic
# Grant Cloud Run service account access to the secret
gcloud secrets add-iam-policy-binding db-password --member=serviceAccount:my-service-sa@my-project.iam.gserviceaccount.com --role=roles/secretmanager.secretAccessor
# Mount the secret as an environment variable in Cloud Run
gcloud run services update my-service --region=europe-west4 --update-secrets=DB_PASSWORD=db-password:latest
# Or mount the secret as a volume (file)
gcloud run services update my-service --region=europe-west4 --update-secrets=/secrets/db-password=db-password:latest
Mounting as a volume has advantages: the file is updated when you create a new secret version (after a cache TTL), and you can set file permissions. Mounting as an environment variable is simpler but requires a service redeployment to pick up new secret versions.
Accessing Secrets Programmatically
For secrets that change at runtime (API keys rotated by an external system), access Secret Manager directly:
from google.cloud import secretmanager
from functools import lru_cache
import time
_secret_client = secretmanager.SecretManagerServiceClient()
@lru_cache(maxsize=None)
def _get_secret_cached(secret_name: str, version: str, cache_bust: int) -> str:
"""Cached secret access — cache_bust forces refresh every 5 minutes."""
name = f"projects/my-project/secrets/{secret_name}/versions/{version}"
response = _secret_client.access_secret_version(request={"name": name})
return response.payload.data.decode("UTF-8")
def get_secret(secret_name: str, version: str = "latest") -> str:
"""Get secret with 5-minute cache."""
# Cache bust every 5 minutes to pick up rotated secrets
cache_bust = int(time.time()) // 300
return _get_secret_cached(secret_name, version, cache_bust)
Secret Rotation
Rotate secrets without service downtime:
# Create a new version of the secret
echo -n "new-database-password" | gcloud secrets versions add db-password --data-file=-
# The old version is still active during rotation
# After updating the database to accept the new password:
gcloud secrets versions disable db-password --version=1
# Eventually destroy the old version
gcloud secrets versions destroy db-password --version=1
Layer 4: VPC Service Controls (Data Exfiltration Prevention)
VPC Service Controls (VPC-SC) create a security perimeter around GCP services — even if someone steals credentials from your Cloud Run service, they can't use those credentials to exfiltrate data outside the perimeter.
# Create an access policy (organization level)
gcloud access-context-manager policies create --organization=ORGANIZATION_ID --title="Production Security Perimeter"
# Create a service perimeter around your production resources
gcloud access-context-manager perimeters create production-perimeter --policy=POLICY_ID --title="Production" --resources=projects/my-project --restricted-services=bigquery.googleapis.com,storage.googleapis.com,secretmanager.googleapis.com
With VPC-SC in place, API calls from Cloud Run to BigQuery are only allowed if they originate from within the perimeter. Stolen credentials used from outside the perimeter will receive 403 VPC Service Controls errors.
Audit Logging for Serverless Workloads
Enable Data Access audit logs for services your Cloud Run workloads touch:
# Enable audit logging for Secret Manager (DATA_READ shows who accessed secrets)
gcloud projects get-iam-policy my-project --format=json > policy.json
# Add audit config to policy.json:
# {
# "auditConfigs": [{
# "service": "secretmanager.googleapis.com",
# "auditLogConfigs": [
# {"logType": "DATA_READ"},
# {"logType": "DATA_WRITE"},
# {"logType": "ADMIN_READ"}
# ]
# }]
# }
gcloud projects set-iam-policy my-project policy.json
Query audit logs for secret access:
gcloud logging read 'resource.type="audited_resource" AND protoPayload.serviceName="secretmanager.googleapis.com" AND protoPayload.methodName="google.cloud.secretmanager.v1.SecretManagerService.AccessSecretVersion"' --freshness=24h --format="table(timestamp,protoPayload.authenticationInfo.principalEmail,protoPayload.resourceName)"
This gives you a complete audit trail of which service account accessed which secret and when — critical for compliance and incident investigation.
Complete Security Deployment Template
#!/bin/bash
# deploy-secure-service.sh
SERVICE_NAME="payment-service"
PROJECT_ID="my-project"
REGION="europe-west4"
IMAGE="europe-west4-docker.pkg.dev/$PROJECT_ID/repo/$SERVICE_NAME:latest"
SA_EMAIL="$SERVICE_NAME-sa@$PROJECT_ID.iam.gserviceaccount.com"
gcloud run deploy $SERVICE_NAME --image=$IMAGE --region=$REGION --platform=managed --no-allow-unauthenticated --service-account=$SA_EMAIL --vpc-connector=my-vpc-connector --vpc-egress=all-traffic --update-secrets=DB_PASSWORD=db-password:latest,STRIPE_SECRET_KEY=stripe-secret:latest,JWT_SECRET=jwt-secret:latest --set-env-vars=APP_ENV=production,LOG_LEVEL=info --memory=1Gi --cpu=2 --concurrency=20 --min-instances=1 --max-instances=50 --timeout=60 --execution-environment=gen2
This template deploys a Cloud Run service with authenticated access only, a dedicated least-privilege service account, VPC routing, secrets from Secret Manager (not environment variables), and sensible scaling configuration.
For the broader GCP security posture, see our Security Command Center guide. For Zero Trust network access patterns, see our BeyondCorp Enterprise guide.