Introduction – A Common Shortcut
Many developers, eager to ship a serverless prototype, store API keys, database credentials, or TLS certificates in a plain‑text object inside an S3 bucket and let the function read it at runtime. The code looks tidy, the deployment is fast, and the “quick‑and‑dirty” solution passes internal QA. This article walks through that exact pattern, demonstrates how easy it is to expose critical secrets, and then shows the correct alternative using a managed secret store. The goal is not to demonize S3 but to expose a hidden risk that often goes unnoticed until a breach occurs.
Step 1 – Building the Naïve Lambda Function
The following AWS SAM template creates a simple Node.js Lambda that fetches config.json from an S3 bucket named my‑public‑secrets. The bucket is deliberately set to PublicRead for illustration.
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
BadSecretFetcher:
Type: AWS::Serverless::Function
Properties:
Runtime: nodejs20.x
Handler: index.handler
CodeUri: ./src
Environment:
Variables:
BUCKET_NAME: my-public-secrets
OBJECT_KEY: config.json
Policies:
- S3ReadPolicy:
BucketName: !Ref BUCKET_NAME
The function code ( src/index.js ) reads the object synchronously using the AWS SDK:
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const s3 = new S3Client({ region: process.env.AWS_REGION });
exports.handler = async () => {
const bucket = process.env.BUCKET_NAME;
const key = process.env.OBJECT_KEY;
try {
const data = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
const body = await streamToString(data.Body);
const config = JSON.parse(body);
// Use the secret (e.g., DB password) to connect to a database
console.log('Fetched secret:', config.dbPassword);
return { statusCode: 200, body: 'Success' };
} catch (err) {
console.error('Error fetching secret:', err);
return { statusCode: 500, body: 'Failure' };
}
};
const streamToString = (stream) =>
new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
Deploy the stack with sam build && sam deploy --guided. Once the function is live, invoke it via the AWS console or CLI and observe the log line that prints the plaintext password.
Step 2 – Demonstrating the Exposure
Because the bucket is public, anyone with the object URL can download the secrets without authentication. Test it with curl:
curl https://my-public-secrets.s3.amazonaws.com/config.json
The response reveals the exact JSON the Lambda consumed. In a real environment, an attacker could automate enumeration of buckets, harvest credentials, and pivot into the underlying services (databases, third‑party APIs, etc.). The risk is amplified when the same bucket is used across multiple environments (dev, staging, prod) because a single public object compromises all of them.
Step 3 – Refactoring to AWS Secrets Manager
The secure pattern stores secrets in AWS Secrets Manager (or Parameter Store with encryption). Secrets are retrieved via IAM‑bound permissions, and the values never touch a public endpoint. Below is a revised SAM template that removes the S3 bucket and adds a Secrets Manager policy.
Resources:
GoodSecretFetcher:
Type: AWS::Serverless::Function
Properties:
Runtime: nodejs20.x
Handler: index.handler
CodeUri: ./src
Environment:
Variables:
SECRET_ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:myApp/dbCreds-ABC123
Policies:
- SecretsManagerReadWritePolicy:
SecretArn: !Ref SECRET_ARN
The updated function code now uses the Secrets Manager SDK:
const {
SecretsManagerClient,
GetSecretValueCommand,
} = require('@aws-sdk/client-secrets-manager');
const secretsClient = new SecretsManagerClient({ region: process.env.AWS_REGION });
exports.handler = async () => {
const secretArn = process.env.SECRET_ARN;
try {
const data = await secretsClient.send(
new GetSecretValueCommand({ SecretId: secretArn })
);
const secretString = data.SecretString;
const config = JSON.parse(secretString);
console.log('Fetched secret from Secrets Manager:', config.dbPassword);
return { statusCode: 200, body: 'Success' };
} catch (err) {
console.error('Error fetching secret:', err);
return { statusCode: 500, body: 'Failure' };
}
};
Deploy the updated stack. Now, invoking the function succeeds, but a direct curl request to any URL will not reveal the secret because the data lives only inside the Secrets Manager service, guarded by IAM policies and optional rotation.
Step 4 – Adding Automated Rotation (Optional)
Secrets Manager can rotate credentials automatically. The following AWS CLI command creates a rotation schedule for a MySQL password stored in the secret:
aws secretsmanager rotate-secret \
--secret-id arn:aws:secretsmanager:us-east-1:123456789012:secret:myApp/dbCreds-ABC123 \
--rotation-lambda-arn arn:aws:lambda:us-east-1:123456789012:function:RotateMySQLPassword \
--rotation-interval-in-days 30
The rotation Lambda must have permissions to read/write the secret and to update the database password. By delegating rotation, you eliminate static credentials and reduce the window of exposure if a secret ever leaks.
Security and Best Practices
Never store plaintext secrets in a publicly readable bucket. Even if the bucket is “private” today, misconfigurations, accidental policy changes, or third‑party tooling can expose it later. Always treat secret storage as a privileged operation.
Principle of least privilege. Grant the Lambda only the secretsmanager:GetSecretValue action on the specific ARN it needs. Avoid wildcard policies such as secretsmanager:* on *.
Enable secret rotation. Rotation reduces the impact of a potential leak. Pair it with audit logging (CloudTrail) to track who accessed which secret and when.
Version your secrets. Secrets Manager maintains versions; you can roll back if a rotation introduces an issue. Store the version identifier in your Lambda’s environment variables if you need to pin a specific version temporarily.
“Security is not a feature you add after the fact; it’s a contract you enforce from the first line of code.”
Conclusion
The allure of a quick S3‑based secret store is understandable, but the hidden liability is severe: a single public object can hand an attacker unfettered access to every downstream system. By switching to a managed secret service, coupling it with strict IAM policies, and enabling rotation, you convert a fragile shortcut into a robust, auditable security posture.
The code snippets above illustrate both the vulnerable pattern and the hardened replacement. When building serverless workloads, always ask yourself whether the convenience of “anywhere read” outweighs the potential cost of a breach. In most cases, the answer is a clear “no.”