Module 5: Secrets Management
5. Cloud KMS: AWS, GCP, Azure
Overview
In capsule 03 you met HashiCorp Vault as the enterprise reference for secrets management. Vault is powerful but requires infrastructure and expertise to operate. For most teams — startups, mid-size teams, and projects that already run on a cloud provider — managed secrets management services are the most pragmatic option: zero infrastructure to operate, native IAM integration, and predictable costs.
This capsule covers the three main ones: AWS Secrets Manager (boto3), GCP Secret Manager (google-cloud-secret-manager), and Azure Key Vault (azure-keyvault-secrets). You'll see working Python code for each, a detailed comparison, and a unified interface pattern that abstracts the provider — so your application doesn't depend on a specific vendor.
By the end you'll be able to choose the right KMS for your setup, implement access from Python, and have a clear migration path from .env.
AWS Secrets Manager
AWS Secrets Manager is the most widely adopted service for secrets management in the cloud. It offers encrypted storage, automatic rotation with Lambda, and access control via IAM.
Setup
pip install boto3
For local development, configure AWS credentials:
aws configure
# Or use environment variables:
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_DEFAULT_REGION="us-east-1"
Operations with boto3
import boto3
import json
from datetime import datetime
from typing import Optional
from botocore.exceptions import ClientError
class AWSSecretsClient:
"""Client for AWS Secrets Manager with common operations."""
def __init__(self, region_name: str = "us-east-1"):
self.client = boto3.client("secretsmanager", region_name=region_name)
self.region = region_name
def create_secret(self, name: str, value: dict, description: str = "") -> dict:
try:
response = self.client.create_secret(
Name=name,
Description=description,
SecretString=json.dumps(value),
Tags=[
{"Key": "managed-by", "Value": "secrets-management-module"},
{"Key": "created-at", "Value": datetime.utcnow().isoformat()},
],
)
return {
"status": "created",
"arn": response["ARN"],
"name": response["Name"],
"version_id": response["VersionId"],
}
except ClientError as e:
if e.response["Error"]["Code"] == "ResourceExistsException":
return self.update_secret(name, value)
raise
def get_secret(self, name: str) -> Optional[dict]:
try:
response = self.client.get_secret_value(SecretId=name)
secret_data = json.loads(response["SecretString"])
return {
"data": secret_data,
"version_id": response["VersionId"],
"created_date": str(response.get("CreatedDate", "")),
}
except ClientError as e:
error_code = e.response["Error"]["Code"]
if error_code in ("ResourceNotFoundException", "DecryptionFailureException"):
return None
raise
def update_secret(self, name: str, value: dict) -> dict:
response = self.client.update_secret(
SecretId=name,
SecretString=json.dumps(value),
)
return {
"status": "updated",
"arn": response["ARN"],
"version_id": response["VersionId"],
}
def delete_secret(self, name: str, force: bool = False) -> dict:
kwargs = {"SecretId": name}
if force:
kwargs["ForceDeleteWithoutRecovery"] = True
else:
kwargs["RecoveryWindowInDays"] = 7
response = self.client.delete_secret(**kwargs)
return {
"status": "scheduled_deletion" if not force else "deleted",
"name": response["Name"],
"deletion_date": str(response.get("DeletionDate", "")),
}
def list_secrets(self, prefix: str = "") -> list[dict]:
paginator = self.client.get_paginator("list_secrets")
secrets = []
for page in paginator.paginate():
for secret in page["SecretList"]:
if prefix and not secret["Name"].startswith(prefix):
continue
secrets.append({
"name": secret["Name"],
"arn": secret["ARN"],
"last_changed": str(secret.get("LastChangedDate", "")),
"last_accessed": str(secret.get("LastAccessedDate", "")),
})
return secrets
def configure_rotation(
self, name: str, lambda_arn: str, days: int = 30
) -> dict:
response = self.client.rotate_secret(
SecretId=name,
RotationLambdaARN=lambda_arn,
RotationRules={"AutomaticallyAfterDays": days},
)
return {
"status": "rotation_configured",
"version_id": response["VersionId"],
"rotation_days": days,
}
# Usage example (requires AWS credentials configured):
# aws_client = AWSSecretsClient(region_name="us-east-1")
#
# aws_client.create_secret(
# "prod/llm/openai",
# {"api_key": "sk-proj-abc123", "org_id": "org-xyz"},
# description="OpenAI API key for production"
# )
#
# secret = aws_client.get_secret("prod/llm/openai")
# print(f"API Key: {secret['data']['api_key']}")
AWS Secrets Manager cost
aws_pricing = {
"per_secret_per_month": 0.40,
"per_10k_api_calls": 0.05,
"examples": [
{"secrets": 5, "calls_per_month": 10000, "cost": 5 * 0.40 + 1 * 0.05},
{"secrets": 20, "calls_per_month": 100000, "cost": 20 * 0.40 + 10 * 0.05},
{"secrets": 50, "calls_per_month": 500000, "cost": 50 * 0.40 + 50 * 0.05},
],
}
print("AWS Secrets Manager Pricing:")
print(f" ${aws_pricing['per_secret_per_month']}/secret/month")
print(f" ${aws_pricing['per_10k_api_calls']}/10K API calls")
print("\nEstimated monthly costs:")
for ex in aws_pricing["examples"]:
print(f" {ex['secrets']} secrets, {ex['calls_per_month']:,} calls → ${ex['cost']:.2f}/month")
# Expected output:
# AWS Secrets Manager Pricing:
# $0.4/secret/month
# $0.05/10K API calls
#
# Estimated monthly costs:
# 5 secrets, 10,000 calls → $2.05/month
# 20 secrets, 100,000 calls → $8.50/month
# 50 secrets, 500,000 calls → $22.50/month
GCP Secret Manager
Google Cloud Secret Manager is GCP's native option with direct integration with IAM and Cloud Audit Logs.
Setup
pip install google-cloud-secret-manager
For local development:
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"
# Or use gcloud auth:
gcloud auth application-default login
Operations with google-cloud-secret-manager
from typing import Optional
import json
class GCPSecretsClient:
"""Client for GCP Secret Manager with common operations."""
def __init__(self, project_id: str):
from google.cloud import secretmanager
self.client = secretmanager.SecretManagerServiceClient()
self.project_id = project_id
self.parent = f"projects/{project_id}"
def create_secret(self, secret_id: str, value: dict) -> dict:
from google.cloud import secretmanager
from google.api_core.exceptions import AlreadyExists
try:
secret = self.client.create_secret(
request={
"parent": self.parent,
"secret_id": secret_id,
"secret": {
"replication": {"automatic": {}},
"labels": {"managed-by": "secrets-management-module"},
},
}
)
except AlreadyExists:
pass
payload = json.dumps(value).encode("UTF-8")
version = self.client.add_secret_version(
request={
"parent": f"{self.parent}/secrets/{secret_id}",
"payload": {"data": payload},
}
)
return {
"status": "created",
"name": version.name,
"state": version.state.name,
}
def get_secret(self, secret_id: str, version: str = "latest") -> Optional[dict]:
from google.api_core.exceptions import NotFound
try:
name = f"{self.parent}/secrets/{secret_id}/versions/{version}"
response = self.client.access_secret_version(request={"name": name})
secret_data = json.loads(response.payload.data.decode("UTF-8"))
return {
"data": secret_data,
"version": response.name.split("/")[-1],
}
except NotFound:
return None
def delete_secret(self, secret_id: str) -> bool:
from google.api_core.exceptions import NotFound
try:
name = f"{self.parent}/secrets/{secret_id}"
self.client.delete_secret(request={"name": name})
return True
except NotFound:
return False
def list_secrets(self, prefix: str = "") -> list[dict]:
secrets = []
for secret in self.client.list_secrets(request={"parent": self.parent}):
name = secret.name.split("/")[-1]
if prefix and not name.startswith(prefix):
continue
secrets.append({
"name": name,
"full_name": secret.name,
"created": str(secret.create_time),
"labels": dict(secret.labels),
})
return secrets
# Usage example (requires GCP credentials configured):
# gcp_client = GCPSecretsClient(project_id="my-ai-project")
#
# gcp_client.create_secret("openai-api-key", {"api_key": "sk-proj-abc123"})
#
# secret = gcp_client.get_secret("openai-api-key")
# print(f"API Key: {secret['data']['api_key']}")
Azure Key Vault
Azure Key Vault is Azure's native option with integration with Azure Active Directory and RBAC.
Setup
pip install azure-keyvault-secrets azure-identity
Operations with azure-keyvault-secrets
import json
from typing import Optional
class AzureSecretsClient:
"""Client for Azure Key Vault with common operations."""
def __init__(self, vault_url: str):
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
credential = DefaultAzureCredential()
self.client = SecretClient(vault_url=vault_url, credential=credential)
self.vault_url = vault_url
def create_secret(self, name: str, value: dict, content_type: str = "application/json") -> dict:
secret = self.client.set_secret(
name=name,
value=json.dumps(value),
content_type=content_type,
tags={"managed-by": "secrets-management-module"},
)
return {
"status": "created",
"name": secret.name,
"version": secret.properties.version,
"created_on": str(secret.properties.created_on),
}
def get_secret(self, name: str, version: str = "") -> Optional[dict]:
from azure.core.exceptions import ResourceNotFoundError
try:
if version:
secret = self.client.get_secret(name, version)
else:
secret = self.client.get_secret(name)
secret_data = json.loads(secret.value)
return {
"data": secret_data,
"version": secret.properties.version,
"created_on": str(secret.properties.created_on),
}
except ResourceNotFoundError:
return None
def delete_secret(self, name: str) -> bool:
from azure.core.exceptions import ResourceNotFoundError
try:
self.client.begin_delete_secret(name).wait()
return True
except ResourceNotFoundError:
return False
def list_secrets(self, prefix: str = "") -> list[dict]:
secrets = []
for prop in self.client.list_properties_of_secrets():
if prefix and not prop.name.startswith(prefix):
continue
secrets.append({
"name": prop.name,
"enabled": prop.enabled,
"created_on": str(prop.created_on),
"updated_on": str(prop.updated_on),
"tags": dict(prop.tags) if prop.tags else {},
})
return secrets
# Usage example (requires Azure credentials configured):
# azure_client = AzureSecretsClient(
# vault_url="https://my-vault.vault.azure.net"
# )
#
# azure_client.create_secret("openai-api-key", {"api_key": "sk-proj-abc123"})
#
# secret = azure_client.get_secret("openai-api-key")
# print(f"API Key: {secret['data']['api_key']}")
Detailed comparison: AWS vs GCP vs Azure
comparison = {
"headers": ["Feature", "AWS Secrets Manager", "GCP Secret Manager", "Azure Key Vault"],
"rows": [
["Encryption", "AES-256 (AWS KMS)", "AES-256 (Google KMS)", "AES-256 (Azure KMS)"],
["Versioning", "Staging labels", "Numbered versions", "Named versions"],
["Rotation", "Lambda-based auto", "Cloud Functions (manual setup)", "Managed identity rotation"],
["Audit", "CloudTrail", "Cloud Audit Logs", "Azure Monitor"],
["Access Control", "IAM policies", "IAM + conditions", "Azure RBAC"],
["Pricing (per secret/mo)", "$0.40", "$0.06", "$0.03/10K ops"],
["API Calls pricing", "$0.05/10K", "$0.03/10K", "Included in ops"],
["Max secret size", "64 KB", "64 KB", "25 KB (secret), 128 KB (cert)"],
["Cross-region", "Replication available", "Automatic replication", "Geo-replication"],
["Python SDK", "boto3", "google-cloud-secret-manager", "azure-keyvault-secrets"],
["Free tier", "No (30-day trial)", "6 active versions free", "10K ops free/month"],
["Best for", "AWS-native apps", "GCP-native, cost-sensitive", "Azure/Microsoft shops"],
],
}
print(f"| {' | '.join(comparison['headers'])} |")
print(f"| {' | '.join(['---'] * len(comparison['headers']))} |")
for row in comparison["rows"]:
print(f"| {' | '.join(row)} |")
Unified Interface Pattern
The most important pattern in this capsule: a unified interface that abstracts the secrets provider. Your application shouldn't know whether it uses AWS, GCP, Azure, or Vault:
import os
import json
import time
import logging
from abc import ABC, abstractmethod
from typing import Optional
from dataclasses import dataclass, field
logger = logging.getLogger("secrets_client")
@dataclass
class SecretResult:
key: str
value: Optional[str]
found: bool
provider: str
version: str = ""
cached: bool = False
access_time_ms: float = 0.0
class SecretsProvider(ABC):
"""Unified interface for any secrets provider."""
@abstractmethod
def get(self, key: str) -> SecretResult:
pass
@abstractmethod
def set(self, key: str, value: dict) -> bool:
pass
@abstractmethod
def delete(self, key: str) -> bool:
pass
@abstractmethod
def list(self, prefix: str = "") -> list[str]:
pass
@property
@abstractmethod
def provider_name(self) -> str:
pass
class EnvProvider(SecretsProvider):
"""Provider for local development using environment variables."""
def get(self, key: str) -> SecretResult:
start = time.perf_counter()
value = os.environ.get(key)
elapsed = (time.perf_counter() - start) * 1000
return SecretResult(
key=key, value=value, found=value is not None,
provider="env", access_time_ms=elapsed,
)
def set(self, key: str, value: dict) -> bool:
os.environ[key] = json.dumps(value) if isinstance(value, dict) else str(value)
return True
def delete(self, key: str) -> bool:
if key in os.environ:
del os.environ[key]
return True
return False
def list(self, prefix: str = "") -> list[str]:
return [k for k in os.environ if k.startswith(prefix)]
@property
def provider_name(self) -> str:
return "environment"
class AWSProvider(SecretsProvider):
"""Provider for AWS Secrets Manager."""
def __init__(self, region: str = "us-east-1"):
import boto3
self.client = boto3.client("secretsmanager", region_name=region)
def get(self, key: str) -> SecretResult:
start = time.perf_counter()
try:
response = self.client.get_secret_value(SecretId=key)
data = json.loads(response["SecretString"])
elapsed = (time.perf_counter() - start) * 1000
return SecretResult(
key=key,
value=data.get("value", json.dumps(data)),
found=True,
provider="aws",
version=response.get("VersionId", ""),
access_time_ms=elapsed,
)
except Exception:
elapsed = (time.perf_counter() - start) * 1000
return SecretResult(
key=key, value=None, found=False,
provider="aws", access_time_ms=elapsed,
)
def set(self, key: str, value: dict) -> bool:
try:
self.client.create_secret(Name=key, SecretString=json.dumps(value))
return True
except self.client.exceptions.ResourceExistsException:
self.client.update_secret(SecretId=key, SecretString=json.dumps(value))
return True
except Exception:
return False
def delete(self, key: str) -> bool:
try:
self.client.delete_secret(SecretId=key, ForceDeleteWithoutRecovery=True)
return True
except Exception:
return False
def list(self, prefix: str = "") -> list[str]:
secrets = []
paginator = self.client.get_paginator("list_secrets")
for page in paginator.paginate():
for s in page["SecretList"]:
if not prefix or s["Name"].startswith(prefix):
secrets.append(s["Name"])
return secrets
@property
def provider_name(self) -> str:
return "aws_secrets_manager"
class CachedProvider(SecretsProvider):
"""Wrapper that adds caching to any provider."""
def __init__(self, provider: SecretsProvider, ttl_seconds: int = 300):
self._provider = provider
self._ttl = ttl_seconds
self._cache: dict[str, tuple[SecretResult, float]] = {}
def get(self, key: str) -> SecretResult:
if key in self._cache:
result, cached_at = self._cache[key]
if time.time() - cached_at < self._ttl:
result.cached = True
return result
del self._cache[key]
result = self._provider.get(key)
if result.found:
self._cache[key] = (result, time.time())
return result
def set(self, key: str, value: dict) -> bool:
success = self._provider.set(key, value)
if success:
self._cache.pop(key, None)
return success
def delete(self, key: str) -> bool:
success = self._provider.delete(key)
self._cache.pop(key, None)
return success
def list(self, prefix: str = "") -> list[str]:
return self._provider.list(prefix)
@property
def provider_name(self) -> str:
return f"cached({self._provider.provider_name})"
def clear_cache(self):
self._cache.clear()
class FallbackProvider(SecretsProvider):
"""Tries multiple providers in order — for gradual migration."""
def __init__(self, providers: list[SecretsProvider]):
self._providers = providers
def get(self, key: str) -> SecretResult:
for provider in self._providers:
result = provider.get(key)
if result.found:
return result
return SecretResult(
key=key, value=None, found=False,
provider="fallback_exhausted",
)
def set(self, key: str, value: dict) -> bool:
return self._providers[0].set(key, value)
def delete(self, key: str) -> bool:
return self._providers[0].delete(key)
def list(self, prefix: str = "") -> list[str]:
all_keys = set()
for provider in self._providers:
all_keys.update(provider.list(prefix))
return sorted(all_keys)
@property
def provider_name(self) -> str:
names = [p.provider_name for p in self._providers]
return f"fallback({', '.join(names)})"
def create_secrets_provider(
provider_type: str = "env",
cache_ttl: int = 300,
**kwargs,
) -> SecretsProvider:
"""Factory function to create the appropriate provider."""
providers = {
"env": lambda: EnvProvider(),
"aws": lambda: AWSProvider(region=kwargs.get("region", "us-east-1")),
}
if provider_type not in providers:
raise ValueError(f"Unknown provider: {provider_type}. Options: {list(providers.keys())}")
provider = providers[provider_type]()
if cache_ttl > 0:
provider = CachedProvider(provider, ttl_seconds=cache_ttl)
return provider
os.environ["OPENAI_API_KEY"] = "sk-proj-from-env"
provider = create_secrets_provider("env", cache_ttl=60)
result = provider.get("OPENAI_API_KEY")
print(f"Provider: {provider.provider_name}")
print(f"Key: {result.key}, Value: {result.value[:15]}..., Found: {result.found}")
result2 = provider.get("OPENAI_API_KEY")
print(f"Cached: {result2.cached}")
# Expected output:
# Provider: cached(environment)
# Key: OPENAI_API_KEY, Value: sk-proj-from-en..., Found: True
# Cached: True
Migrating from .env to Cloud KMS
Step-by-step migration plan
migration_plan = {
"phase_1_preparation": {
"duration": "1-2 days",
"steps": [
"Inventory all secrets in .env",
"Classify by risk (CRITICAL, HIGH, MEDIUM, LOW)",
"Choose a provider (AWS, GCP, Azure) based on your infrastructure",
"Create an account/project in the provider",
"Configure IAM roles/policies",
],
},
"phase_2_abstraction": {
"duration": "1 day",
"steps": [
"Implement the SecretsProvider interface in your code",
"Replace os.getenv() with secrets_provider.get()",
"Use FallbackProvider: cloud → env (for backward compatibility)",
"Verify the app works the same with the new abstraction layer",
],
},
"phase_3_migration": {
"duration": "1-2 days",
"steps": [
"Create secrets in the cloud provider (start with CRITICAL)",
"Deploy with FallbackProvider: cloud first, env as backup",
"Monitor: verify secrets are read from the cloud",
"Migrate HIGH and MEDIUM secrets",
"Verify the audit trail in the cloud provider",
],
},
"phase_4_cleanup": {
"duration": "1 day",
"steps": [
"Remove secrets from .env (leave only LOW/non-sensitive config)",
"Remove FallbackProvider → use only the cloud provider",
"Configure rotation schedules",
"Document the new secrets architecture",
"Configure rotation alerts",
],
},
}
print("Migration Plan: .env → Cloud KMS")
for phase, info in migration_plan.items():
print(f"\n📋 {phase.replace('_', ' ').title()} ({info['duration']})")
for i, step in enumerate(info["steps"], 1):
print(f" {i}. {step}")
Migration script
import os
import json
from typing import Optional
def parse_env_file(filepath: str) -> dict[str, str]:
"""Parses a .env file and returns a dictionary of secrets."""
secrets = {}
try:
with open(filepath, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
secrets[key.strip()] = value.strip().strip('"').strip("'")
except FileNotFoundError:
pass
return secrets
def classify_secrets(secrets: dict[str, str]) -> dict[str, list[str]]:
"""Classifies secrets by risk level based on the name."""
critical_patterns = ["api_key", "secret_key", "password", "token"]
high_patterns = ["database", "redis", "url"]
medium_patterns = ["webhook", "smtp", "sentry"]
classified = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
for key in secrets:
key_lower = key.lower()
if any(p in key_lower for p in critical_patterns):
classified["CRITICAL"].append(key)
elif any(p in key_lower for p in high_patterns):
classified["HIGH"].append(key)
elif any(p in key_lower for p in medium_patterns):
classified["MEDIUM"].append(key)
else:
classified["LOW"].append(key)
return classified
env_content = """
OPENAI_API_KEY=sk-proj-abc123
ANTHROPIC_API_KEY=sk-ant-xyz789
DATABASE_URL=postgresql://user:pass@host:5432/db
DATABASE_PASSWORD=super-secret
REDIS_URL=redis://:pass@host:6379
SENTRY_DSN=https://key@sentry.io/123
APP_ENV=production
LOG_LEVEL=info
PORT=8000
""".strip()
with open(".env.example", "w") as f:
f.write(env_content)
secrets = parse_env_file(".env.example")
classified = classify_secrets(secrets)
print("Secret Classification:")
for level, keys in classified.items():
if keys:
print(f"\n {level}:")
for key in keys:
print(f" - {key}")
print(f"\nMigration priority:")
print(f" 1. CRITICAL ({len(classified['CRITICAL'])} secrets) → Migrate first")
print(f" 2. HIGH ({len(classified['HIGH'])} secrets) → Migrate second")
print(f" 3. MEDIUM ({len(classified['MEDIUM'])} secrets) → Migrate third")
print(f" 4. LOW ({len(classified['LOW'])} secrets) → Keep in env vars")
os.remove(".env.example")
# Expected output:
# Secret Classification:
# CRITICAL:
# - OPENAI_API_KEY
# - ANTHROPIC_API_KEY
# - DATABASE_PASSWORD
# HIGH:
# - DATABASE_URL
# - REDIS_URL
# MEDIUM:
# - SENTRY_DSN
# LOW:
# - APP_ENV
# - LOG_LEVEL
# - PORT
Choosing the right KMS
decision_tree = {
"question_1": {
"q": "Which cloud provider do you run your application on?",
"aws": "→ AWS Secrets Manager (native integration, IAM)",
"gcp": "→ GCP Secret Manager (cheaper, Cloud Audit Logs)",
"azure": "→ Azure Key Vault (Azure AD, native RBAC)",
"multi_cloud": "→ HashiCorp Vault (provider-agnostic)",
"on_premises": "→ HashiCorp Vault (self-hosted)",
},
"question_2": {
"q": "How many secrets do you manage?",
"less_than_10": "Cloud KMS is more than enough (~$2-4/month)",
"10_to_50": "Cloud KMS is still practical (~$5-20/month)",
"50_plus": "Evaluate Vault if you need dynamic secrets",
},
"question_3": {
"q": "Do you need dynamic secrets (temporary credentials)?",
"yes": "→ HashiCorp Vault (the only one that supports this natively)",
"no": "→ Cloud KMS with automatic rotation is enough",
},
}
print("Decision Tree: Which KMS to use?")
for name, tree in decision_tree.items():
print(f"\n{tree['q']}")
for option, recommendation in tree.items():
if option != "q":
print(f" {option}: {recommendation}")
Troubleshooting
"boto3 throws NoCredentialsError"
Verify that you have AWS credentials configured: aws configure or the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. For local development, use AWS SSO or profiles.
"GCP throws PermissionDenied"
The service account needs the roles/secretmanager.secretAccessor role to read secrets and roles/secretmanager.admin to create/delete. Check in IAM & Admin.
"Azure throws AuthenticationError"
DefaultAzureCredential tries multiple authentication methods in order. For local development, use az login. For production, use Managed Identity.
"The migration broke my app in production"
Use FallbackProvider during migration: the cloud provider as the first option and env vars as backup. That way, if the cloud provider fails or a secret wasn't migrated, the app keeps working with the value from .env.
"I can't test locally without cloud credentials"
Use EnvProvider for local development and AWSProvider/GCPProvider for staging/production. The unified interface makes the switch transparent.
Exercises
Exercise 1: Implement a GCPProvider
Following the AWSProvider pattern, implement a GCPProvider that implements the SecretsProvider interface:
See solution
class GCPProvider(SecretsProvider):
def __init__(self, project_id: str):
from google.cloud import secretmanager
self.client = secretmanager.SecretManagerServiceClient()
self.project_id = project_id
self.parent = f"projects/{project_id}"
def get(self, key: str) -> SecretResult:
start = time.perf_counter()
try:
name = f"{self.parent}/secrets/{key}/versions/latest"
response = self.client.access_secret_version(request={"name": name})
value = response.payload.data.decode("UTF-8")
elapsed = (time.perf_counter() - start) * 1000
return SecretResult(key=key, value=value, found=True, provider="gcp", access_time_ms=elapsed)
except Exception:
elapsed = (time.perf_counter() - start) * 1000
return SecretResult(key=key, value=None, found=False, provider="gcp", access_time_ms=elapsed)
def set(self, key: str, value: dict) -> bool:
try:
from google.api_core.exceptions import AlreadyExists
try:
self.client.create_secret(request={
"parent": self.parent,
"secret_id": key,
"secret": {"replication": {"automatic": {}}},
})
except AlreadyExists:
pass
self.client.add_secret_version(request={
"parent": f"{self.parent}/secrets/{key}",
"payload": {"data": json.dumps(value).encode("UTF-8")},
})
return True
except Exception:
return False
def delete(self, key: str) -> bool:
try:
self.client.delete_secret(request={"name": f"{self.parent}/secrets/{key}"})
return True
except Exception:
return False
def list(self, prefix: str = "") -> list[str]:
secrets = []
for s in self.client.list_secrets(request={"parent": self.parent}):
name = s.name.split("/")[-1]
if not prefix or name.startswith(prefix):
secrets.append(name)
return secrets
@property
def provider_name(self) -> str:
return "gcp_secret_manager"
Exercise 2: Migrate 5 secrets from .env to a provider
Write a script that reads a .env, classifies the secrets, and migrates them to the provider you choose:
See solution
def migrate_env_to_provider(env_path: str, provider: SecretsProvider) -> dict:
secrets = parse_env_file(env_path)
classified = classify_secrets(secrets)
results = {"migrated": [], "skipped": [], "failed": []}
for level in ["CRITICAL", "HIGH", "MEDIUM"]:
for key in classified.get(level, []):
try:
success = provider.set(key, {"value": secrets[key]})
if success:
results["migrated"].append({"key": key, "level": level})
else:
results["failed"].append({"key": key, "level": level})
except Exception as e:
results["failed"].append({"key": key, "error": str(e)})
for key in classified.get("LOW", []):
results["skipped"].append({"key": key, "reason": "LOW risk — keep in env"})
return results
provider = EnvProvider()
# In production you'd use: provider = AWSProvider() or GCPProvider()
Exercise 3: Implement a health check for the secrets provider
Create an endpoint that verifies connectivity with the secrets provider:
See solution
def health_check(provider: SecretsProvider) -> dict:
start = time.perf_counter()
try:
result = provider.get("__health_check__")
elapsed = (time.perf_counter() - start) * 1000
return {
"provider": provider.provider_name,
"status": "healthy",
"response_time_ms": round(elapsed, 2),
"note": "Secret not found is expected for health check",
}
except Exception as e:
elapsed = (time.perf_counter() - start) * 1000
return {
"provider": provider.provider_name,
"status": "unhealthy",
"response_time_ms": round(elapsed, 2),
"error": str(e),
}
check = health_check(EnvProvider())
print(json.dumps(check, indent=2))
Exercise 4: Compare latency between providers
Write a benchmark that compares read latency between EnvProvider and CachedProvider:
See solution
def benchmark_provider(provider: SecretsProvider, key: str, iterations: int = 100) -> dict:
times = []
for _ in range(iterations):
start = time.perf_counter()
provider.get(key)
elapsed = (time.perf_counter() - start) * 1000
times.append(elapsed)
return {
"provider": provider.provider_name,
"iterations": iterations,
"avg_ms": round(sum(times) / len(times), 4),
"min_ms": round(min(times), 4),
"max_ms": round(max(times), 4),
"p99_ms": round(sorted(times)[int(len(times) * 0.99)], 4),
}
os.environ["BENCH_KEY"] = "test-value"
env = EnvProvider()
cached = CachedProvider(env, ttl_seconds=60)
cached.get("BENCH_KEY")
print("Benchmark Results:")
for result in [benchmark_provider(env, "BENCH_KEY"), benchmark_provider(cached, "BENCH_KEY")]:
print(f" {result['provider']}: avg={result['avg_ms']}ms, p99={result['p99_ms']}ms")
Summary
- The three main cloud KMS (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) offer encrypted storage, versioning, and audit — with no infrastructure to operate
- AWS Secrets Manager is the most mature with automatic rotation via Lambda and the widest adoption
- GCP Secret Manager is the most affordable and has direct integration with Cloud Audit Logs
- Azure Key Vault integrates natively with Azure AD and RBAC for Microsoft organizations
- The unified interface pattern (
SecretsProvider) abstracts the provider so your app doesn't depend on a specific vendor - CachedProvider adds caching with a TTL to reduce latency and calls to the cloud service
- FallbackProvider enables gradual migration: cloud as primary, env as backup
- The migration from .env to cloud KMS is incremental: classify → abstract → migrate → clean up
- Monthly cost for most AI projects: $2-20/month — significantly less than the cost of a leaked key
Next capsule: In capsule 06 you'll learn about token lifecycle management and audit trails — how to manage the complete lifecycle of your secrets (creation, distribution, rotation, revocation) and how to record every access for compliance and incident detection.
Resources
- AWS Secrets Manager Documentation — Official documentation for AWS's secrets service
- GCP Secret Manager Documentation — Official documentation for Google Cloud's secrets service
- Azure Key Vault Documentation — Official documentation for Azure's secrets service
- boto3 Secrets Manager Reference — Python SDK reference for AWS
- google-cloud-secret-manager PyPI — Python package for GCP Secret Manager
- azure-keyvault-secrets PyPI — Python package for Azure Key Vault
- AWS Secrets Manager Pricing — AWS pricing details for cost planning
- Multi-Cloud Secrets Management (CNCF) — CNCF guide for managing secrets across multi-cloud environments
Created: March 2026 Version: 1.0