Laptop to Production

Secrets

Keeping passwords out of code for good.

Time
35 minutes
Level
Beginner

Part 1: The problem

QuickCart, a Friday afternoon

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

PlaceWhy it leaks
In the codeEvery clone of the repository has it, and git history keeps it after you delete the line
A .env file committed to gitThe same. Adding it to .gitignore later doesn’t remove it from history
A .env file on each laptop, never committedBetter, 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
LogsOne debug line that prints the config, and the password is in your log system for months
A container imageEvery layer of the image keeps its files, even ones a later step deletes. Writing a Dockerfile shows why
Terraform stateAny secret Terraform handles is stored in plain text. See Terraform state
Key point: Write a secret down in exactly one place. Everything else holds only its name, and permission to read it.

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, SecureStringSecrets Manager
What it isPart of Systems Manager. A store for settings, where a SecureString parameter is encrypted with a KMS keyA store built only for secrets
CostStandard parameters have no storage chargeAbout $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 scheduleNo. You’d have to build that yourselfYes. This is called rotation
Largest value4 KB (8 KB for advanced parameters)64 KB
Keeps old valuesYes, a history of versionsYes. The current value is labelled AWSCURRENT and the one before it AWSPREVIOUS
Works directly with RDSNoYes. RDS can create and rotate its own master password in Secrets Manager
Best forSettings, and secrets that rarely changeDatabase 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:

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-*"
    }
  ]
}
LineWhat 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 endSecrets 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:

secret.py
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"])
LineWhat 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:

  1. 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.
  2. Then move it out of the code and into Secrets Manager, so it doesn’t happen again.
  3. 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.

The database password lives
  1. Your editorwhere the code is written

    Not reached yet

  2. Git historyevery clone, forever

    Not reached yet

  3. The buildCI logs and the app package

    Not reached yet

  4. Server settingslaunch template and user data

    Not reached yet

  5. The running appin memory while it runs

    Not reached yet

Readable copies of the password: 0Places to change when it changes: –

ReadyChoose where the password lives, then Follow the password from the editor to the running app.

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.

  1. Store the secret

    In the AWS console: Secrets ManagerSecretsStore a new secret

    On the first page, Choose secret type:

    FieldEnter
    Secret typeOther type of secret
    Key/value pairs, row 1Key username, value quickcart_app
    Key/value pairs, row 2Choose Add row. Key password, value practice-only-change-me
    Encryption keyaws/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:

    FieldEnter
    Secret namequickcart/dev/db
    DescriptionQuickCart dev database login

    Leave 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 see

    quickcart/dev/db in the list of secrets. Open it: the Secret ARN ends in quickcart/dev/db- followed by six random characters. Those six characters are why the policy in the next step ends with -*.

  2. 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.

    FieldEnter
    Policy namequickcart-read-db-secret

    Choose Create policy.

  3. Create the role for the server

    In the AWS console: IAMRolesCreate role

    FieldEnter
    Trusted entity typeAWS service
    Service or use caseEC2, then use case EC2

    Choose Next. On Add permissions, search for and tick both of these:

    PolicyWhy
    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.

    FieldEnter
    Role namequickcart-app-role

    Choose 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.

  4. Launch a small server with the role

    In the AWS console: EC2InstancesLaunch instances

    FieldEnter
    Namequickcart-secrets-lab
    Application and OS ImagesAmazon Linux 2023 AMI — the default
    Instance typet3.micro
    Key pair (login)Proceed without a key pair
    Network settings → VPCThe default VPC
    Network settings → Auto-assign public IPEnable
    Network settings → FirewallCreate security group, and untick Allow SSH traffic from. The server needs no inbound rules at all
    Advanced details → IAM instance profilequickcart-app-role
    Advanced details → Metadata versionV2 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 see

    The instance in the list, with Instance state: Running after a minute or so.

  5. 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:

    Terminal
    aws secretsmanager get-secret-value --secret-id quickcart/dev/db --query SecretString --output text --region us-east-1
    PartWhat 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-role from the instance profile, and they renew by themselves.

  6. Prove the role can do nothing else

    Still on the server, ask for the list of every secret in the account:

    Terminal
    aws secretsmanager list-secrets --region us-east-1
    PartWhat it means
    aws secretsmanager list-secretsask for every secret in the account, by name
    --region us-east-1in this region
    It's refused
    An 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 action

    That refusal is what you want. The server can read its one password, and can’t even find out what other secrets exist.

  7. Change the password in one place

    Back in the console:

    In the AWS console: Secrets ManagerSecretsquickcart/dev/dbRetrieve secret valueEdit

    FieldEnter
    passwordpractice-only-second-value

    Choose Save. Now, in the Session Manager terminal, run the same command as before:

    Terminal
    aws secretsmanager get-secret-value --secret-id quickcart/dev/db --query SecretString --output text --region us-east-1
    PartWhat 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:

    Terminal
    aws secretsmanager get-secret-value --secret-id quickcart/dev/db --version-stage AWSPREVIOUS --query SecretString --output text --region us-east-1
    PartWhat 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.

  8. Compare with Parameter Store

    In the AWS console: Systems ManagerParameter StoreCreate parameter

    FieldEnter
    Name/quickcart/dev/db-password
    TierStandard
    TypeSecureString
    KMS key sourceMy current account
    KMS Key IDalias/aws/ssm — the default
    Valuepractice-only-change-me

    Choose Create parameter. Then, on the server:

    Terminal
    aws ssm get-parameter --name /quickcart/dev/db-password --with-decryption --query Parameter.Value --output text --region us-east-1
    PartWhat 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 see
    practice-only-change-me

    Look 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.

  9. 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 7 and choose Schedule deletion.

    You should see

    The 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 see

    No 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

On one card
Rule or commandWhat it means
One secret, one placeCode and settings hold the secret’s name, never its value
Secrets ManagerPasswords and anything that should rotate. About $0.40 a secret a month
Parameter Store SecureStringSettings and rarely changing secrets. Standard parameters cost nothing to store
Policy resourcearn:aws:secretsmanager:REGION:ACCOUNT:secret:NAME-* — the -* is required
aws secretsmanager get-secret-value --secret-id NAMERead a secret. Add --version-stage AWSPREVIOUS for the one before
aws ssm get-parameter --name NAME --with-decryptionRead a SecureString parameter
AmazonSSMManagedInstanceCoreLets Session Manager in, and can read every parameter in the account
RotationChanges the secret on a schedule. The app must re-fetch when the database rejects it
A hostnameNot a secret. Keep it in plain config
Leaked?Change it first. Clean up the code and history after

Finished the build and the quiz? Mark it done to fill in its stop on the route map.