DEV Community

Cover image for Manage Sensitive Data In Application Code | πŸ—οΈ Build A Secure Configuration Service

Manage Sensitive Data In Application Code | πŸ—οΈ Build A Secure Configuration Service

Exam Guide: Developer - Associate
πŸ—οΈ Domain 2: Security
πŸ“˜ Task 3: Manage Sensitive Data In Application Code

Managing Sensitive Data In Application Code is about keeping secrets out of your code, classifying data properly, and building applications that handle sensitive data safely.

You need to know when to use Secrets Manager vs Parameter Store, how to mask PII in API responses and logs, and how to isolate data in multi-tenant applications.

The ability to choose the right secret management service, implement data sanitization, and enforce tenant-level data boundaries, is vital.


πŸ“˜Concepts

Data Classification

Understand data sensitivity levels and how each should be handled:

Classification Examples Handling Requirements
PII (Personally Identifiable Information) Name, email, SSN, phone number, address Encrypt at rest and in transit, mask in logs and API responses, restrict access
PHI (Protected Health Information) Medical records, insurance IDs, lab results HIPAA compliance, encryption required, audit trail mandatory
Financial Credit card numbers, bank accounts, transaction data PCI DSS compliance, tokenization, never store full card numbers
Public Marketing content, public API docs No special handling needed

πŸ’‘If a scenario mentions compliance or audit trail, think encryption with KMS (for CloudTrail logging) and Secrets Manager (for automatic rotation). If it mentions PII in logs, think data masking and sanitization.

Secrets Manager vs SSM Parameter Store

Both store configuration and secrets.

Feature Secrets Manager SSM Parameter Store
Automatic rotation Yes (built-in for RDS, Redshift, DocumentDB) No (you build it yourself with Lambda)
Cost $0.40/secret/month + $0.05 per 10,000 API calls Free (Standard tier), $0.05/advanced parameter/month
Cross-account access Yes (via resource policy) Yes (advanced parameters only)
Max size 64 KB 4 KB (Standard) / 8 KB (Advanced)
Versioning Automatic (AWSCURRENT, AWSPREVIOUS labels) Yes (version history)
Encryption Always encrypted (KMS required) Optional (SecureString type uses KMS)
CloudFormation resolve {{resolve:secretsmanager:...}} {{resolve:ssm:...}}
Best for Database credentials, API keys that need rotation App config, feature flags, non-rotating secrets

πŸ’‘If it says automatic rotation or database credentials, the answer is Secrets Manager.
If it says application configuration, feature flags, or free, the answer is Parameter Store.
If it says encrypted configuration, Parameter Store with SecureString works and costs nothing.

Data Masking and Sanitization Patterns

Pattern What It Does When to Use
Field Masking Replace characters with asterisks (j***e@example.com) API responses containing PII
Log Sanitization Strip or redact sensitive fields before logging Any log output that might contain secrets
Tokenization Replace sensitive data with a non-reversible token Credit card numbers, SSNs in databases
Field-Level Encryption Encrypt individual fields within a record When only some fields in a record are sensitive

Multi-Tenant Data Isolation Patterns

Pattern How It Works Isolation Level
Partition Key Prefix Prefix DynamoDB keys with tenant ID (TENANT#123#ORDER#456) Logical (application-enforced)
IAM Condition Keys Use dynamodb:LeadingKeys condition to restrict access Policy-enforced
Cognito Claims Extract tenant ID from JWT and scope all queries Token-Enforced
Separate Tables/Accounts Each tenant gets their own table or AWS account Physical (strongest isolation)

πŸ’‘ Partition key isolation with IAM condition keys is the most common pattern. Separate accounts is the strongest but most expensive.
dynamodb:LeadingKeys restricts which partition keys a principal can access.

When to Use Each Secret Management Approach

Scenario Service Why
RDS database password that rotates every 30 days Secrets Manager Built-in rotation for RDS
API endpoint URL that differs per environment Parameter Store (String) Simple config, no encryption needed, free
Third-party API key that doesn't rotate 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']
            })
        }
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

Test event 2: Get app config:

{"action": "get_app_config"}
Enter fullscreen mode Exit fullscreen mode

Test event 3: Get feature flags:

{"action": "get_feature_flags"}
Enter fullscreen mode Exit fullscreen mode

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
        })
    }
Enter fullscreen mode Exit fullscreen mode

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"}
}
Enter fullscreen mode Exit fullscreen mode

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"}
}
Enter fullscreen mode Exit fullscreen mode

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"}
}
Enter fullscreen mode Exit fullscreen mode

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
        })
    }
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

Tenant B query:

{"tenantId": "tenant-b", "userId": "user-002"}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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

  1. Secrets Manager β†’ Select prod/database/credentials β†’ Actions β†’ Delete secret (set minimum 7-day waiting period)
  2. Systems Manager β†’ Parameter Store β†’ Select all parameters β†’ Delete
  3. Lambda β†’ Delete SecureConfigService, DataMaskingDemo, and MultiTenantQuery
  4. DynamoDB β†’ Delete the MultiTenantApp table
  5. IAM β†’ Delete Lambda execution roles
  6. CloudWatch β†’ Delete log groups for all three functions

Key Takeaways

  1. Secrets Manager for credentials that need automatic rotation (RDS, Redshift, DocumentDB).
  2. Parameter Store for app config and non-rotating secrets.
  3. SecureString parameters are encrypted with KMS. Always use WithDecryption=True to read them.
  4. Don't resolve secrets at deploy time ({{resolve:...}}) if they rotate fetch at runtime with caching instead.
  5. Lambda environment variables are encrypted at rest by default. Use a CMK + in-code decryption for additional security.
  6. Mask PII in API responses and sanitize logs.
  7. Never log passwords, SSNs, or credit card numbers.
  8. Multi-tenant isolation: use partition key prefixes combined with IAM condition keys (dynamodb:LeadingKeys) for policy-enforced isolation.
  9. Secrets Manager costs $0.40/secret/month. Parameter Store Standard is free. Choose based on rotation needs, not just cost.
  10. 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


πŸ—οΈ

Top comments (0)