Copied to Clipboard
| Parameter Store (SecureString) |
Encrypted, free, no rotation needed |
| OAuth client secret shared across accounts |
Secrets Manager |
Cross-account resource policies |
| Feature flag (enable/disable a feature) |
Parameter Store (String) or AppConfig |
Simple config value |
| Lambda environment variable with a password |
KMS encryption + runtime decryption |
Encrypted at rest, decrypted at cold start |
ποΈ Build A Secure Configuration Service
Build a Secure Configuration Service that demonstrates secret and configuration management:
- Database credentials stored in Secrets Manager with console-based setup
- Application configuration stored in SSM Parameter Store
- A Lambda function that retrieves secrets and config at runtime
- Data masking applied to API responses containing PII
- A multi-tenant data isolation pattern using DynamoDB with partition key prefixes and IAM conditions
Prerequisites
An AWS account
Part I
Store Database Credentials in Secrets Manager
Step 01: Open the Secrets Manager console
Step 02: Click Store a new secret
Step 03: Choose secret type
-
Secret type:
Other type of secret
-
Key:
username, Value: admin
-
Key:
password, Value: SuperSecretPass123!
-
Key:
host, Value: mydb.cluster-abc123.us-east-1.rds.amazonaws.com
-
Key:
port, Value: 3306
-
Key:
dbname, Value: orders
Step 04: Encryption key: aws/secretsmanager
Step 05: Click Next
Step 06: Configure secret
-
Secret name:
prod/database/credentials
-
Description:
Production database credentials for the orders service
Step 07: Click Next
Step 08: Configure rotation - optional
Leave disabled for this tutorial (in production, you'd enable automatic rotation for RDS)
Step 09: Click Next
Step 10: Review
Step 11: Click Store
You now have a secret stored in Secrets Manager.
Click into it and note the Secret ARN.
Explore the Secret
Step 12: Click on prod/database/credentials
In the Secret value section, click Retrieve secret value
You'll see your key/value pairs displayed.
Step 13: Click the Versions tab
Notice the AWSCURRENT staging label
π‘Secrets Manager automatically versions secrets. When rotation happens, the new value gets AWSCURRENT and the old value gets AWSPREVIOUS. Your application always retrieves AWSCURRENT by default, so rotation is seamless.
Part II
Store Application Config in SSM Parameter Store
Create String Parameters
Step 01: Open the Systems Manager console
Step 02: In the left sidebar βΌ Application Tools, click Parameter Store
Step 03: Click Create parameter
-
Name:
/app/config/api-url
-
Description:
External API endpoint URL
-
Tier:
Standard
-
Type:
String
-
Data type:
text
-
Value:
https://api.example.com/v2
Step 04: Click Create parameter
Create a SecureString Parameter
Step 05: Click Create parameter again
-
Name:
/app/secrets/api-key
-
Description:
Third-party API key (encrypted)
-
Tier:
Standard
-
Type:
SecureString
-
KMS key source:
My current account
-
KMS Key ID:
alias/aws/ssm
-
Value:
sk-abc123-this-is-a-secret-key
Step 06: Click Create parameter
Create a Hierarchical Config Set
Step 07: Create three more parameters following the same steps:
-
Name:
/app/config/max-items | Type: String | Value: 50
-
Name:
/app/config/feature-flags/new-checkout | Type: String | Value: true
-
Name:
/app/config/feature-flags/dark-mode | Type: String | Value: false
You now have a parameter hierarchy. The /app/config/ prefix groups related configuration together.
π‘ Parameter Store supports hierarchies with path-based names. You can retrieve all parameters under a path with GetParametersByPath. This is useful for loading all config for an environment at once. SecureString parameters are encrypted with KMS. Use WithDecryption=True when retrieving them.
Part III
Create a Lambda Function That Retrieves Both
Create the Lambda Function
Step 01: Open the Lambda console β Create function
-
Function name:
SecureConfigService
-
Runtime: Python 3.12
Step 02: Click Create function
Step 03: Paste this code
import json
import boto3
from functools import lru_cache
secrets_client = boto3.client('secretsmanager')
ssm_client = boto3.client('ssm')
@lru_cache(maxsize=None)
def get_secret(secret_name):
"""
Retrieve a secret from Secrets Manager.
Cached at cold start to avoid repeated API calls.
"""
response = secrets_client.get_secret_value(SecretId=secret_name)
return json.loads(response['SecretString'])
@lru_cache(maxsize=None)
def get_parameter(name, decrypt=True):
"""
Retrieve a parameter from SSM Parameter Store.
Use WithDecryption=True for SecureString parameters.
"""
response = ssm_client.get_parameter(Name=name, WithDecryption=decrypt)
return response['Parameter']['Value']
def get_all_config(path):
"""Retrieve all parameters under a path hierarchy."""
response = ssm_client.get_parameters_by_path(
Path=path,
Recursive=True,
WithDecryption=True
)
return {p['Name']: p['Value'] for p in response['Parameters']}
def lambda_handler(event, context):
"""
Demonstrates retrieving secrets and config from both services.
Key patterns:
- Cache secrets at cold start (lru_cache) to reduce API calls
- Use Secrets Manager for credentials that rotate
- Use Parameter Store for app config and non-rotating secrets
- Use GetParametersByPath to load config hierarchies
"""
action = event.get('action', 'get_all')
if action == 'get_db_credentials':
# From Secrets Manager
creds = get_secret('prod/database/credentials')
return {
'statusCode': 200,
'body': json.dumps({
'message': 'Database credentials retrieved from Secrets Manager',
'host': creds['host'],
'port': creds['port'],
'dbname': creds['dbname'],
'username': creds['username'],
'password': '***REDACTED***' # Never return passwords in responses!
})
}
elif action == 'get_app_config':
# From Parameter Store
api_url = get_parameter('/app/config/api-url')
api_key = get_parameter('/app/secrets/api-key')
max_items = get_parameter('/app/config/max-items')
return {
'statusCode': 200,
'body': json.dumps({
'message': 'App config retrieved from Parameter Store',
'apiUrl': api_url,
'apiKey': api_key[:8] + '***REDACTED***', # Mask the key
'maxItems': int(max_items)
})
}
elif action == 'get_feature_flags':
# Load all feature flags under a path
flags = get_all_config('/app/config/feature-flags')
return {
'statusCode': 200,
'body': json.dumps({
'message': 'Feature flags loaded from Parameter Store',
'flags': {k.split('/')[-1]: v == 'true' for k, v in flags.items()}
})
}
else:
return {
'statusCode': 200,
'body': json.dumps({
'message': 'Secure Config Service',
'availableActions': ['get_db_credentials', 'get_app_config', 'get_feature_flags']
})
}
Step 04: Paste Click Deploy
Add Permissions to the Lambda Role
Step 05: Go to Configuration β Permissions β click the role name
Step 06: Click Add permissions β Attach policies
Step 07: Search for and attach:
SecretsManagerReadWrite
AmazonSSMReadOnlyAccess
Step 08: Click Add permissions
Test the Function
Step 09: Go to the Test tab
Step 10: Create test events
Test event 1: Get database credentials:
{"action":"get_db_credentials"}
Test event 2: Get app config:
{"action":"get_app_config"}
Test event 3: Get feature flags:
{"action":"get_feature_flags"}
Step 11: Run each test and verify the responses
π‘Notice the caching pattern with @lru_cache. Secrets Manager and Parameter Store charge per API call.
Caching at cold start means you only pay for one call per Lambda instance, not one per invocation.
For secrets that rotate, use a TTL-based cache instead of lru_cache so the Lambda picks up new values.
Part IV
Implement Data Masking in API Responses
Create the Data Masking Lambda
Step 01: Lambda β Create function
-
Function name:
DataMaskingDemo
-
Runtime: Python 3.12
Step 02: Click Create function
Step 03: Paste this code
import json
import re
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# --- Data Masking Functions ---
def mask_email(email):
"""jane.doe@example.com β j***e@example.com"""
local, domain = email.split('@')
if len(local) <= 2:
masked = local[0] + '***'
else:
masked = local[0] + '***' + local[-1]
return f"{masked}@{domain}"
def mask_phone(phone):
"""+1-555-123-4567 β ***-***-**67"""
digits = re.sub(r'\D', '', phone)
return f"***-***-**{digits[-2:]}"
def mask_ssn(ssn):
"""123-45-6789 β ***-**-6789"""
return f"***-**-{ssn[-4:]}"
def mask_credit_card(card):
"""4111-1111-1111-1234 β ****-****-****-1234"""
digits = re.sub(r'\D', '', card)
return f"****-****-****-{digits[-4:]}"
def sanitize_user(user):
"""Mask all PII fields before returning to the client."""
return {
'id': user['id'],
'name': user['name'],
'email': mask_email(user['email']),
'phone': mask_phone(user['phone']),
'ssn': mask_ssn(user['ssn']),
'memberSince': user['memberSince']
}
# --- Log Sanitization ---
SENSITIVE_PATTERNS = {
'password': r'"password"\s*:\s*"[^"]*"',
'ssn': r'\d{3}-\d{2}-\d{4}',
'credit_card': r'\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}'
}
def sanitize_for_logging(data):
"""Remove sensitive data before logging."""
text = json.dumps(data) if isinstance(data, dict) else str(data)
for field, pattern in SENSITIVE_PATTERNS.items():
text = re.sub(pattern, f'"***REDACTED-{field}***"', text)
return text
def lambda_handler(event, context):
"""
Demonstrates data masking in API responses and log sanitization.
Key patterns:
- Mask PII fields (email, phone, SSN) before returning to clients
- Sanitize log output to prevent sensitive data from reaching CloudWatch
- Never log passwords, full credit card numbers, or SSNs
"""
# Simulated user data (in production, this comes from DynamoDB)
users = [
{
'id': 'USR-001',
'name': 'Jane Doe',
'email': 'jane.doe@example.com',
'phone': '+1-555-123-4567',
'ssn': '123-45-6789',
'memberSince': '2023εΉ΄01ζ15ζ₯'
},
{
'id': 'USR-002',
'name': 'John Smith',
'email': 'john.smith@example.com',
'phone': '+1-555-987-6543',
'ssn': '987-65-4321',
'memberSince': '2023εΉ΄06ζ20ζ₯'
}
]
# Sanitize before logging β never log raw PII
logger.info(f"Processing request: {sanitize_for_logging(event)}")
# Mask PII before returning in the response
masked_users = [sanitize_user(u) for u in users]
return {
'statusCode': 200,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({
'message': 'User data with PII masked',
'users': masked_users
})
}
Step 04: Click Deploy
Test Data Masking
Step 05: Go to the Test tab β create a test event with {}
Step 06: Run the test
β οΈ Verify the response shows masked values:
- Email:
j***e@example.com
- Phone:
***-***-**67
- SSN:
***-**-6789
Step 07: Check CloudWatch Logs to confirm the log output is also sanitized
π‘ Data masking happens at the application layer, not the database layer. Always mask PII before returning it in API responses.
Always sanitize data before logging.
CloudWatch Logs can be accessed by anyone with the right IAM permissions, so treat logs as potentially public.
Part V
Build a Multi-Tenant Data Isolation Pattern
Create the DynamoDB Table
Step 01: Open the DynamoDB console β Create table
-
Table name:
MultiTenantApp
-
Partition key:
PK (String)
-
Sort key:
SK (String)
-
Table settings:
Customize settings
-
Read/write capacity settings:
On-demand
Step 02: Click Create table
Add Sample Data
Step 03: Click into the MultiTenantApp table
Step 04: Click Explore table items β Create item
Step 05: Switch to JSON view and add these items one at a time:
Tenant A: Order 1:
{"PK":{"S":"TENANT#tenant-a#USER#user-001"},"SK":{"S":"ORDER#2024-001"},"orderTotal":{"N":"149.99"},"status":{"S":"shipped"},"tenantId":{"S":"tenant-a"}}
Tenant A: Order 2:
{"PK":{"S":"TENANT#tenant-a#USER#user-001"},"SK":{"S":"ORDER#2024-002"},"orderTotal":{"N":"89.50"},"status":{"S":"processing"},"tenantId":{"S":"tenant-a"}}
Tenant B: Order 1:
{"PK":{"S":"TENANT#tenant-b#USER#user-002"},"SK":{"S":"ORDER#2024-003"},"orderTotal":{"N":"299.00"},"status":{"S":"delivered"},"tenantId":{"S":"tenant-b"}}
Create the Multi-Tenant Lambda
Step 06: Lambda β Create function
-
Function name:
MultiTenantQuery
-
Runtime: Python 3.12
Step 07: Paste this code:
import json
import boto3
from boto3.dynamodb.conditions import Key
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('MultiTenantApp')
def lambda_handler(event, context):
"""
Multi-tenant data isolation using partition key prefixes.
The tenant ID is extracted from the request context (simulating
a Cognito JWT claim) and used to scope all database queries.
A user in Tenant A can never see Tenant B's data.
In production, the tenant ID comes from:
- Cognito custom claims (custom:tenantId)
- Lambda authorizer context
- API Gateway request context
"""
# In production: extract from JWT claims
# tenant_id = event['requestContext']['authorizer']['claims']['custom:tenantId']
tenant_id = event.get('tenantId', 'tenant-a')
user_id = event.get('userId', 'user-001')
# Query is automatically scoped to this tenant
pk = f"TENANT#{tenant_id}#USER#{user_id}"
response = table.query(
KeyConditionExpression=Key('PK').eq(pk) & Key('SK').begins_with('ORDER#')
)
orders = []
for item in response['Items']:
orders.append({
'orderId': item['SK'].replace('ORDER#', ''),
'total': str(item['orderTotal']),
'status': item['status']
})
return {
'statusCode': 200,
'body': json.dumps({
'tenantId': tenant_id,
'userId': user_id,
'orderCount': len(orders),
'orders': orders
})
}
Step 08: Click Deploy
Step 09: Add AmazonDynamoDBReadOnlyAccess to the Lambda execution role
Test Tenant Isolation
Step 10: Create test events
Tenant A query:
{"tenantId":"tenant-a","userId":"user-001"}
Tenant B query:
{"tenantId":"tenant-b","userId":"user-002"}
β οΈ Run each test and verify that Tenant A only sees their orders and Tenant B only sees theirs
π‘ Partition key isolation is the most common multi-tenant pattern on the exam. For stronger isolation, combine it with IAM condition keys using dynamodb:LeadingKeys. This enforces isolation at the IAM policy level so even a bug in your application code can't leak data across tenants.
ποΈ What You Built | π Exam Concepts Recap
| What You Built |
Exam Concept |
| Stored database credentials in Secrets Manager |
Secret management for rotating credentials |
Explored the AWSCURRENT staging label |
Secrets Manager versioning and seamless rotation |
| Created String and SecureString parameters |
Parameter Store types. Plaintext vs KMS-encrypted |
Built a parameter hierarchy under /app/config/ |
Path-based config organization, GetParametersByPath |
Cached secrets with @lru_cache at cold start |
Reducing API calls and cost for secret retrieval |
Retrieved SecureString with WithDecryption=True |
KMS decryption of encrypted parameters |
| Masked email, phone, SSN in API responses |
Application-level PII data masking |
| Sanitized log output before writing to CloudWatch |
Preventing sensitive data leakage in logs |
Used partition key prefixes (TENANT#...) |
Multi-tenant data isolation pattern |
| Scoped queries to a tenant from request context |
Token/claim-based tenant boundary enforcement |
β οΈ Clean Up Protocol
-
Secrets Manager β Select
prod/database/credentials β Actions β Delete secret (set minimum 7-day waiting period)
-
Systems Manager β Parameter Store β Select all parameters β Delete
-
Lambda β Delete
SecureConfigService, DataMaskingDemo, and MultiTenantQuery
-
DynamoDB β Delete the
MultiTenantApp table
-
IAM β Delete Lambda execution roles
-
CloudWatch β Delete log groups for all three functions
Key Takeaways
-
Secrets Manager for credentials that need automatic rotation (RDS, Redshift, DocumentDB).
-
Parameter Store for app config and non-rotating secrets.
-
SecureString parameters are encrypted with KMS. Always use
WithDecryption=True to read them.
-
Don't resolve secrets at deploy time (
{{resolve:...}}) if they rotate fetch at runtime with caching instead.
-
Lambda environment variables are encrypted at rest by default. Use a CMK + in-code decryption for additional security.
-
Mask PII in API responses and sanitize logs.
- Never log passwords, SSNs, or credit card numbers.
-
Multi-tenant isolation: use partition key prefixes combined with IAM condition keys (
dynamodb:LeadingKeys) for policy-enforced isolation.
-
Secrets Manager costs 0γγ«.40/secret/month. Parameter Store Standard is free. Choose based on rotation needs, not just cost.
-
Cache secrets at cold start to reduce API calls and cost. Use
lru_cache for static secrets, TTL-based cache for rotating secrets.
Additional Resources
ποΈ