Secrets
Keeping passwords out of code for good.
Part 1: The problem
Sam needed the API to reach the database, so he put the password where the code could find it. It went into a .env file next to the code, and the .env file went into git with everything else.
The same file held an AWS access key he had made “just for testing”. A week later, a contractor was given access to the repository to fix one bug. By then the password had also been pasted into a chat thread, typed into the server’s start-up script, and printed once in a log while Sam was debugging.
Nobody could say how many copies of it existed any more. So when the contractor’s laptop was stolen, nobody could say whether the database was safe.
QuickCart needs its database password in exactly one place that it can control, with the app fetching it when it needs it.
Part 2: The idea
A secret is any value that lets someone in: a database password, an API key for a payment provider, a signing key. If someone else gets a copy, they can do what your app can do.
The trouble with secrets is not storing them. It’s that they get copied. Every copy is a place it can leak from, and every copy has to change when the secret changes.
Where secrets usually end up
| Place | Why it leaks |
|---|---|
| In the code | Every clone of the repository has it, and git history keeps it after you delete the line |
A .env file committed to git | The same. Adding it to .gitignore later doesn’t remove it from history |
A .env file on each laptop, never committed | Better, but it gets passed around in chat and email to every new developer |
| Server start-up settings (user data, environment variables) | Readable by anyone allowed to view the server’s settings, and kept in every old version of the launch template |
| Logs | One debug line that prints the config, and the password is in your log system for months |
| A container image | Every layer of the image keeps its files, even ones a later step deletes. Writing a Dockerfile shows why |
| Terraform state | Any secret Terraform handles is stored in plain text. See Terraform state |
The two places AWS gives you
AWS has two services that store secrets encrypted and hand them out only to callers whose IAM policy allows it.
| Parameter Store, SecureString | Secrets Manager | |
|---|---|---|
| What it is | Part of Systems Manager. A store for settings, where a SecureString parameter is encrypted with a KMS key | A store built only for secrets |
| Cost | Standard parameters have no storage charge | About $0.40 per secret a month, plus about $0.05 per 10,000 reads. Prices change; check the pricing page |
| Changes the secret on a schedule | No. You’d have to build that yourself | Yes. This is called rotation |
| Largest value | 4 KB (8 KB for advanced parameters) | 64 KB |
| Keeps old values | Yes, a history of versions | Yes. The current value is labelled AWSCURRENT and the one before it AWSPREVIOUS |
| Works directly with RDS | No | Yes. RDS can create and rotate its own master password in Secrets Manager |
| Best for | Settings, and secrets that rarely change | Database passwords, and anything that should change regularly |
QuickCart puts the database password in Secrets Manager, because it should rotate. Settings like the database’s hostname go in plain parameters or ordinary environment variables. A hostname is not a secret. Anyone who has it still can’t log in, and putting it in a secret only means one more thing to keep in step.
Who may read it: one role, one secret
The API servers get an IAM role, attached to each instance through an instance profile. Servers on EC2 covers how that works. The role’s policy allows reading one secret, and nothing else:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadTheDatabaseSecret",
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:quickcart/dev/db-*"
}
]
}| Line | What it does |
|---|---|
"Version": "2012-10-17" | The version of the policy language. Always this date; it isn’t the day you wrote it |
"Sid" | A label for this statement, for people reading it. Optional |
"Effect": "Allow" | This statement grants permission. Anything not allowed somewhere is denied |
"Action": "secretsmanager:GetSecretValue" | The one thing it may do: read a secret’s value. Not list secrets, not change them, not delete them |
"Resource" | Which secret. 111122223333 is the account, and quickcart/dev/db is the secret’s name |
-* at the end | Secrets Manager adds a dash and six random characters to every secret’s ARN, like quickcart/dev/db-AbC123. Without -*, the policy matches nothing |
A server with this role can read the database password. It can’t read any other secret, and it can’t even list which secrets exist. If it’s ever compromised, the damage stops at that one password.
Fetching it at run time
When the app starts, it asks Secrets Manager for the value, using its role’s credentials. Here is all it takes in Python:
import json
import boto3
client = boto3.client("secretsmanager", region_name="us-east-1")
def db_credentials():
response = client.get_secret_value(SecretId="quickcart/dev/db")
return json.loads(response["SecretString"])
if __name__ == "__main__":
creds = db_credentials()
print("Loaded credentials for user", creds["username"])| Line | What it does |
|---|---|
boto3.client("secretsmanager", …) | Connect to Secrets Manager in us-east-1. On EC2 it finds the role’s credentials by itself; there is no key in the code |
get_secret_value(SecretId="quickcart/dev/db") | Ask for the secret by its name. IAM checks the role’s policy, and CloudTrail records the read |
json.loads(response["SecretString"]) | The secret is stored as JSON, like {"username": "…", "password": "…"}. This turns it into a dictionary |
print(…, creds["username"]) | Prints the user name only. Never log the password, even while debugging |
The code contains the secret’s name, and that’s fine. The name is useless without a role that’s allowed to read it.
Two habits make this work well in a real app:
- Keep it in memory for a while. Don’t fetch it on every request. Every read costs a little, and it’s slower. Fetching once at start, or every few minutes, is plenty.
- When the database rejects the password, fetch it again. That’s what lets rotation happen while the app is running.
Rotation
Rotation means changing a secret on a schedule, say every 30 days, so that a copy that leaked months ago stops working by itself.
Secrets Manager rotates a secret by running a small function. For a database, that function sets a new password in the database and saves it in the secret, as one job. For RDS, you can skip writing any of this: tick Manage master credentials in AWS Secrets Manager when you create the database, and RDS creates the secret and rotates it for you. Managed databases: RDS shows where that option is.
Rotation is only safe if the app follows the second habit above. Straight after a rotation, any server with the old password cached gets rejected once, fetches the new one, and carries on.
When a secret has leaked
The order matters:
- Change it first. Once a secret has been pushed or pasted anywhere, you can’t know who has it. A new value makes every copy useless.
- Then move it out of the code and into Secrets Manager, so it doesn’t happen again.
- Then, if you like, clean it out of git history. That’s tidying up, not the fix: anyone who cloned before you cleaned still has it.
Part 3: See it
Below is the path QuickCart’s database password travels, from the editor where the code is written to the running app. Choose where the password lives, then follow it. Then press Change the password to see how much work a change is.
- Your editorwhere the code is written
Not reached yet
- Git historyevery clone, forever
Not reached yet
- The buildCI logs and the app package
Not reached yet
- Server settingslaunch template and user data
Not reached yet
- The running appin memory while it runs
Not reached yet
Readable copies of the password: 0Places to change when it changes: –
What to notice: the app needs the password in memory whatever you choose. The difference is how many other places it gets written down on the way. With Secrets Manager, there are none, which is also why changing it takes one step instead of a redeploy.
Part 4: Do it
Pennies — one secret in Secrets Manager (about $0.40 a month, charged for the time it exists, so a few cents for this lab), a free Parameter Store parameter, and a t3.micro server for under an hour, about $0.01 plus about $0.005 an hour for its public address. New secrets may also fall under a free trial. Prices change; these are for us-east-1.
You’ll store QuickCart’s database credentials in Secrets Manager, give a server a role that can read exactly that one secret, read it from the server, change it, and compare it with Parameter Store. About 30 minutes.
Check the region selector at the top right of the console says N. Virginia (us-east-1) before you start.
Store the secret
In the AWS console: Secrets ManagerSecretsStore a new secret
On the first page, Choose secret type:
Field Enter Secret type Other type of secret Key/value pairs, row 1 Key username, valuequickcart_appKey/value pairs, row 2 Choose Add row. Key password, valuepractice-only-change-meEncryption key aws/secretsmanager — the default This lab uses a made-up practice password on purpose. For a real database you’d use a long random one.
Choose Next. On Configure secret:
Field Enter Secret name quickcart/dev/dbDescription QuickCart dev database loginLeave everything else as it is. Choose Next. On Configure rotation, leave Automatic rotation off; there’s no real database to rotate yet. Choose Next, check the review page, and choose Store.
You should seequickcart/dev/dbin the list of secrets. Open it: the Secret ARN ends inquickcart/dev/db-followed by six random characters. Those six characters are why the policy in the next step ends with-*.Write a policy that can read only this secret
In the AWS console: IAMPoliciesCreate policy
Switch the Policy editor from Visual to JSON. Delete what’s there and paste this, with your own 12-digit account ID in place of
111122223333. You can copy your account ID from the account menu at the top right of the console.quickcart-read-db-secret.json{ "Version": "2012-10-17", "Statement": [ { "Sid": "ReadTheDatabaseSecret", "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:quickcart/dev/db-*" } ] }Every line is explained in the idea, above. Choose Next.
Field Enter Policy name quickcart-read-db-secretChoose Create policy.
Create the role for the server
In the AWS console: IAMRolesCreate role
Field Enter Trusted entity type AWS service Service or use case EC2, then use case EC2 Choose Next. On Add permissions, search for and tick both of these:
Policy Why quickcart-read-db-secretYour policy: read the one secret AmazonSSMManagedInstanceCoreAWS’s policy that lets Session Manager connect to the server, so you need no SSH key Choose Next.
Field Enter Role name quickcart-app-roleChoose Create role. Because you chose EC2, the console also makes an instance profile with the same name. That’s the thing you attach to a server.
Launch a small server with the role
In the AWS console: EC2InstancesLaunch instances
Field Enter Name quickcart-secrets-labApplication and OS Images Amazon Linux 2023 AMI — the default Instance type t3.microKey pair (login) Proceed without a key pair Network settings → VPC The default VPC Network settings → Auto-assign public IP Enable Network settings → Firewall Create security group, and untick Allow SSH traffic from. The server needs no inbound rules at all Advanced details → IAM instance profile quickcart-app-roleAdvanced details → Metadata version V2 only (token required) Choose Launch instance.
The public address is only there so the server can reach AWS’s services from the default VPC. Nothing can connect in, because the security group allows nothing in.
You should seeThe instance in the list, with Instance state: Running after a minute or so.
Read the secret from the server
Wait two or three minutes after launch, so the Session Manager agent can register. Then:
In the AWS console: EC2Instancesquickcart-secrets-labConnectSession ManagerConnect
A terminal opens in your browser. Run:
Terminalaws secretsmanager get-secret-value --secret-id quickcart/dev/db --query SecretString --output text --region us-east-1Part What it means aws secretsmanager get-secret-valueask Secrets Manager for a secret’s value. The AWS CLI comes installed on Amazon Linux --secret-id quickcart/dev/dbwhich secret, by its name --query SecretStringfrom the reply, keep only the secret itself, not the extra details like its ARN and dates --output textprint it as plain text, not wrapped in more JSON --region us-east-1the region the secret is in You should see{"username":"quickcart_app","password":"practice-only-change-me"}There are no access keys on this server. The CLI got short-lived credentials for
quickcart-app-rolefrom the instance profile, and they renew by themselves.Prove the role can do nothing else
Still on the server, ask for the list of every secret in the account:
Terminalaws secretsmanager list-secrets --region us-east-1Part What it means aws secretsmanager list-secretsask for every secret in the account, by name --region us-east-1in this region It's refusedAn error occurred (AccessDeniedException) when calling the ListSecrets operation: User: arn:aws:sts::111122223333:assumed-role/quickcart-app-role/i-0abc123example is not authorized to perform: secretsmanager:ListSecrets because no identity-based policy allows the secretsmanager:ListSecrets actionThat refusal is what you want. The server can read its one password, and can’t even find out what other secrets exist.
Change the password in one place
Back in the console:
In the AWS console: Secrets ManagerSecretsquickcart/dev/dbRetrieve secret valueEdit
Field Enter passwordpractice-only-second-valueChoose Save. Now, in the Session Manager terminal, run the same command as before:
Terminalaws secretsmanager get-secret-value --secret-id quickcart/dev/db --query SecretString --output text --region us-east-1Part What it means aws secretsmanager get-secret-valueread the secret again --secret-id quickcart/dev/dbthe same secret --query SecretStringkeep only the value --output textas plain text --region us-east-1in this region You should see{"username":"quickcart_app","password":"practice-only-second-value"}You changed nothing on the server. The next read simply got the new value. That’s what rotation relies on.
Secrets Manager kept the old value too, labelled
AWSPREVIOUS. Read it:Terminalaws secretsmanager get-secret-value --secret-id quickcart/dev/db --version-stage AWSPREVIOUS --query SecretString --output text --region us-east-1Part What it means aws secretsmanager get-secret-valueread the secret --secret-id quickcart/dev/dbthe same secret --version-stage AWSPREVIOUSthe value before the latest change, instead of the current one --query SecretStringkeep only the value --output textas plain text --region us-east-1in this region You should see{"username":"quickcart_app","password":"practice-only-change-me"}If a new password ever turns out to be wrong, the previous one is still there to go back to.
Compare with Parameter Store
In the AWS console: Systems ManagerParameter StoreCreate parameter
Field Enter Name /quickcart/dev/db-passwordTier Standard Type SecureString KMS key source My current account KMS Key ID alias/aws/ssm— the defaultValue practice-only-change-meChoose Create parameter. Then, on the server:
Terminalaws ssm get-parameter --name /quickcart/dev/db-password --with-decryption --query Parameter.Value --output text --region us-east-1Part What it means aws ssm get-parameterread one parameter from Parameter Store --name /quickcart/dev/db-passwordwhich one --with-decryptiondecrypt the SecureString. Without it you get the encrypted text --query Parameter.Valuekeep only the value --output textas plain text --region us-east-1in this region You should seepractice-only-change-meLook at what just happened. You never gave this role permission to read that parameter. It worked because
AmazonSSMManagedInstanceCore, the policy that lets Session Manager connect, also allows reading every parameter in the account. So on any server that uses Session Manager, every SecureString parameter is readable.Clean-up step: Clean up
The server
In the AWS console: EC2Instancesquickcart-secrets-labInstance stateTerminate (delete) instance
When it shows Terminated, delete the security group the wizard made: EC2 → Security Groups, tick the one named like
launch-wizard-1, then Actions → Delete security groups.The secret
In the AWS console: Secrets ManagerSecretsquickcart/dev/dbActionsDelete secret
Secrets Manager won’t delete a secret straight away. It makes you choose a waiting period of 7 to 30 days, in case you deleted the wrong one. Enter
7and choose Schedule deletion.You should seeThe secret’s status shows it is scheduled for deletion, with a date. Until then you can cancel.
The parameter
In the AWS console: Systems ManagerParameter Store/quickcart/dev/db-passwordDelete
Confirm with Delete parameters.
The role and the policy
In the AWS console: IAMRolesquickcart-app-roleDelete
Type the role’s name to confirm. Then IAM → Policies, tick
quickcart-read-db-secret, choose Actions → Delete, and type its name to confirm.You should seeNo instance running, no parameter, no role, and one secret waiting to be deleted. Nothing else is left.
Part 5: Check yourself
Sam committed a .env file with the database password. An hour later he deletes it in a new commit. What now?
The app's policy allows GetSecretValue on the secret's ARN, written so it ends with secret:quickcart/dev/db and nothing after. Reading the secret fails with AccessDenied. Why?
QuickCart's database password must change automatically every 30 days. Where should it live?
The API servers have no access keys at all. How do they get permission to read the secret?
The password was just rotated, and the app's cached copy is now wrong. What should the app do when the database rejects it?
Part 6: Cheat sheet
| Rule or command | What it means |
|---|---|
| One secret, one place | Code and settings hold the secret’s name, never its value |
| Secrets Manager | Passwords and anything that should rotate. About $0.40 a secret a month |
| Parameter Store SecureString | Settings and rarely changing secrets. Standard parameters cost nothing to store |
| Policy resource | arn:aws:secretsmanager:REGION:ACCOUNT:secret:NAME-* — the -* is required |
aws secretsmanager get-secret-value --secret-id NAME | Read a secret. Add --version-stage AWSPREVIOUS for the one before |
aws ssm get-parameter --name NAME --with-decryption | Read a SecureString parameter |
AmazonSSMManagedInstanceCore | Lets Session Manager in, and can read every parameter in the account |
| Rotation | Changes the secret on a schedule. The app must re-fetch when the database rejects it |
| A hostname | Not a secret. Keep it in plain config |
| Leaked? | Change it first. Clean up the code and history after |