This document outlines security patterns and best practices for platform applications.
Secrets MUST be fetched at runtime using IAM-based access. Deploy-time injection is FORBIDDEN.
// BAD: Environment variables with secrets
const lambda = new lambda.Function(this, 'Handler', {
environment: {
SLACK_TOKEN: 'xoxb-...', // Secret in code
API_KEY: process.env.API_KEY, // Secret from env
DB_PASSWORD: ssm.StringParameter.valueForStringParameter(this, '/db/password') // Injected at deploy
}
});Why this is dangerous:
- Secrets appear in CloudFormation templates (visible in console)
- Secrets stored in Lambda environment variables (visible to anyone with Lambda read access)
- Secrets logged during deployments
- Secrets cannot be rotated without redeployment
- Audit trail is incomplete
// GOOD: No secrets in infrastructure
const lambda = new lambda.Function(this, 'Handler', {
environment: {
CONFIG_PROFILE: 'production', // Non-sensitive selector only
AWS_REGION: 'us-east-1' // Public information only
}
});
// Grant IAM permissions for runtime access
lambda.addToRolePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ssm:GetParameter', 'ssm:GetParameters'],
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter/slack-bot/production/*`
]
}));Runtime code fetches secrets:
// runtime/handlers/slack-bot/index.ts
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
const ssm = new SSMClient({});
async function getSecret(name: string): Promise<string> {
const command = new GetParameterCommand({
Name: name,
WithDecryption: true
});
const response = await ssm.send(command);
return response.Parameter!.Value!;
}
export async function handler(event: any) {
// Hardcoded path - no environment variable needed
const slackToken = await getSecret('/slack-bot/production/token');
// Use the token...
}/application-name/environment/secret-name
Examples:
/slack-bot/production/token
/slack-bot/production/signing-secret
/terraform-bot/production/api-key
/cost-reporter/production/webhook-url
Least-privilege access per application:
// Grant access to specific application path only
lambda.addToRolePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ssm:GetParameter', 'ssm:GetParameters'],
resources: [
// Application-specific path only
`arn:aws:ssm:${region}:${account}:parameter/slack-bot/production/*`
],
conditions: {
StringEquals: {
'aws:RequestedRegion': region
}
}
}));Why path-based access?
- Knowledge of parameter name is useless without IAM permission
- Applications cannot access each other's secrets
- Clear audit trail of which application accessed which secret
- Easy to manage with IaC
-
Update value in Parameter Store:
aws ssm put-parameter \ --name /slack-bot/production/token \ --value "new-secret-value" \ --type SecureString \ --overwrite -
No redeployment needed - Lambda will fetch new value on next execution
For secrets requiring automatic rotation (database passwords, API keys):
- Use AWS Secrets Manager instead of Parameter Store
- Enable automatic rotation with Lambda rotation function
- Update runtime code to use Secrets Manager SDK
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({});
async function getSecret(secretId: string): Promise<string> {
const command = new GetSecretValueCommand({ SecretId: secretId });
const response = await client.send(command);
return response.SecretString!;
}| Feature | Parameter Store | Secrets Manager |
|---|---|---|
| Cost | Free (Standard), $0.05/parameter (Advanced) | $0.40/secret/month + $0.05/10k API calls |
| Rotation | Manual | Automatic (built-in for RDS, custom for others) |
| Versioning | Manual (overwrite) | Automatic (multiple versions) |
| Encryption | KMS (required for SecureString) | KMS (automatic) |
| Use Case | Most secrets | Database passwords, frequently rotated secrets |
Default: Use Parameter Store unless you need automatic rotation.
Environment variables are ONLY for non-sensitive configuration:
environment: {
CONFIG_PROFILE: 'production', // Environment selector
AWS_REGION: 'us-east-1', // Public information
LOG_LEVEL: 'info', // Non-sensitive config
FEATURE_FLAG_XYZ: 'true', // Feature toggles
MAX_RETRY_ATTEMPTS: '3' // Non-sensitive parameters
}environment: {
SLACK_TOKEN: 'xoxb-...', // Secret value
API_KEY: 'sk-...', // Secret value
DB_PASSWORD: 'password123', // Secret value
PARAM_PATH: '/slack-bot/token', // Even paths are not allowed
SECRET_NAME: 'slack-token' // Secret identifiers
}Why no parameter paths in environment variables?
Even parameter names can leak information:
/slack-bot/prod/admin-tokenreveals the existence of an admin token/db/master-passwordreveals database structure- Parameter paths in CloudFormation are visible to anyone with read access
Hardcode paths in application code instead:
// GOOD: Path is in code, not in environment
const token = await getSecret('/slack-bot/production/token');
// BAD: Path comes from environment
const token = await getSecret(process.env.PARAM_PATH!);Before committing code, verify:
- No secrets in code, comments, or commit messages
- No AWS account IDs (use
Aws.ACCOUNT_IDin CDK) - No hardcoded IPs or private DNS names
- No internal URLs or service endpoints
- No database connection strings
- No API keys or tokens (even revoked ones)
- No parameter paths in environment variables
- No
.envfiles committed (use.gitignore)
Each Lambda function should have:
- Minimal permissions for its specific task
- Path-based restrictions for Parameter Store/Secrets Manager
- Resource-specific ARNs (no
*wildcards) - Condition keys to restrict further
Example:
const handler = new lambda.Function(this, 'SlackBotHandler', {
// ... function config
});
// Specific permission for Parameter Store
handler.addToRolePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ssm:GetParameter'],
resources: [
`arn:aws:ssm:${region}:${account}:parameter/slack-bot/production/*`
]
}));
// Specific permission for SQS (if needed)
handler.addToRolePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['sqs:SendMessage'],
resources: [intentQueue.queueArn]
}));
// No broad permissions like s3:*, dynamodb:*, or ssm:*All Parameter Store and Secrets Manager access is logged:
{
"eventName": "GetParameter",
"requestParameters": {
"name": "/slack-bot/production/token",
"withDecryption": true
},
"userIdentity": {
"principalId": "AIDAI....:slack-bot-handler",
"arn": "arn:aws:sts::123456789012:assumed-role/slack-bot-handler-role/slack-bot-handler"
}
}Set up alarms for suspicious activity:
// Alert on unauthorized access attempts
const unauthorizedAccessMetric = new cloudwatch.Metric({
namespace: 'AWS/SSM',
metricName: 'ParameterStoreUnauthorizedAccess',
statistic: 'Sum'
});
new cloudwatch.Alarm(this, 'UnauthorizedAccess', {
metric: unauthorizedAccessMetric,
threshold: 1,
evaluationPeriods: 1,
alarmDescription: 'Alert on Parameter Store unauthorized access'
});- Parameter Store SecureString: KMS encryption required
- Secrets Manager: KMS encryption automatic
- Use customer-managed KMS keys for additional control:
const kmsKey = new kms.Key(this, 'SecretsKey', {
description: 'KMS key for application secrets',
enableKeyRotation: true
});
// Reference in Parameter Store (created outside CDK)
// aws ssm put-parameter --name /app/secret --value "..." --type SecureString --key-id <key-id>- AWS SDK uses TLS 1.2+ by default
- No additional configuration needed
- Secrets never transmitted in plain text
Use GitHub Secrets for CI/CD credentials:
# .github/workflows/deploy.yml
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1Never:
- Hardcode AWS credentials in workflows
- Use long-lived access keys (use IAM roles with OIDC)
- Store secrets in repository variables (use GitHub Secrets)
// BAD
const token = await getSecret('/slack-bot/token');
console.log(`Token: ${token}`); // Logged to CloudWatch!
// GOOD
const token = await getSecret('/slack-bot/token');
console.log('Token retrieved successfully');// BAD
catch (error) {
throw new Error(`Failed to connect with token ${token}`);
}
// GOOD
catch (error) {
throw new Error('Failed to connect to Slack API');
}// BAD: Cached in memory indefinitely
let cachedToken: string;
async function getToken() {
if (!cachedToken) {
cachedToken = await getSecret('/slack-bot/token');
}
return cachedToken;
}
// GOOD: Time-limited cache with refresh
const cache = new Map<string, { value: string, expiresAt: number }>();
async function getToken(): Promise<string> {
const cached = cache.get('token');
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const value = await getSecret('/slack-bot/token');
cache.set('token', {
value,
expiresAt: Date.now() + 5 * 60 * 1000 // 5 minutes
});
return value;
}Before deploying:
- Secrets fetched at runtime, not at deploy time
- IAM policies follow least-privilege
- Parameter paths hardcoded in code, not in environment
- No secrets in CloudFormation templates
- CloudTrail logging enabled
- KMS encryption for secrets
- Error handling doesn't leak secrets
- Logging doesn't expose sensitive data
- Dependencies are up to date (no known vulnerabilities)
If a secret is leaked:
- Immediately revoke the secret (API token, credential, etc.)
- Rotate the secret in Parameter Store/Secrets Manager
- Verify that Lambda picks up new secret (test execution)
- Audit CloudTrail logs for unauthorized access
- Update security controls to prevent recurrence
- Document the incident and response
For security questions or to report vulnerabilities, contact the platform team.
Remember: If you're unsure whether something is a secret, treat it as a secret.