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 directly
  • all-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.