Skip to content
Secrets in CDK: Secrets Manager, Parameter Store, and never anything in the template
← ← Back to Thinking Cloud

Secrets in CDK: Secrets Manager, Parameter Store, and never anything in the template

A CloudFormation template is a text file. It's stored by CloudFormation, it's in cdk.out on every developer's disk, it's in the CI logs from the last synth, and if someone commits cdk.out by accident, it's in git forever. Anything that appears in the template as a literal value is, for practical purposes, public within the organisation. That includes the database password you set with environment: { DB_PASSWORD: '...' } because it was quicker than doing it properly.

We run three accounts from one CDK codebase with about twenty-five secrets per account, and none of them has ever been in a template. This is the set of rules that makes that true, the two AWS services involved, when to use which, and the patterns for getting a value into a container, a Lambda or a build without a human ever pasting it anywhere.

The rule

The template carries references, never values. A reference is an ARN, a name or a parameter path. The value lives in one of two places, Secrets Manager or SSM Parameter Store, and it's fetched at runtime by the thing that needs it, using an IAM permission granted in the same template. If you can grep a secret out of cdk.out, it's a leak, and CDK makes it easy to check:

npx cdk synth --quiet && grep -rniE 'password|secret|token|api[_-]?key' cdk.out/*.template.json | grep -v 'arn:aws:\|Ref\|Fn::' || echo clean

We run that in CI. It has failed twice, both times on a well-meaning environment: entry in a Lambda.

The two services, and when to use which

Secrets Manager SSM Parameter Store (SecureString)
Price $0.40 per secret per month + $0.05 per 10,000 calls Free at standard tier (4 KB, 10,000 parameters); $0.05 per advanced parameter
Rotation Built in, with Lambda rotation functions for RDS, Aurora, Redshift and custom None; you rotate by writing a new version
Generation Can generate the value itself (generateSecretString) No
Cross-account Resource policy allows another account to read Not directly
Native integration App Runner runtimeEnvironmentSecrets, ECS secrets, Lambda extension, RDS Proxy ECS secrets, Lambda extension, CodeBuild parameter-store
Versioning Staging labels (AWSCURRENT / AWSPREVIOUS) Numbered versions

The rule we settled on: Secrets Manager for anything that rotates, is generated, or is read across accounts. Parameter Store for everything else. In practice that puts the database credentials, the web push signing key and the third-party tokens that we rotate on a schedule in Secrets Manager, and the long list of config-like secrets (feature flags with sensitive values, a shared preview token, per-environment API base URLs with embedded keys) in Parameter Store.

The cost difference matters more than it looks. At $0.40 a secret, twenty-five secrets in three accounts is $30 a month, which was 4 % of our bill. Two moves cut it in half: group related values into one JSON secret instead of one secret per value, and move the config-like ones to Parameter Store, which is free.

a value the app needsrotates? generated?read from another account? yes no Secrets ManagerJSON secret · $0.40 / month · rotation SSM Parameter StoreSecureString · free · versioned template: name or ARN onlyconsumer fetches at runtime · IAM grant environment: { KEY: 'literal' } → in the template → leak

Pattern 1: the secret CloudFormation creates and nobody ever sees

The best secret is one no human has ever read. The database password is the canonical case: CDK asks Secrets Manager to generate it, Aurora is told to use it, and the value exists only in Secrets Manager and in the database.

const dbSecret = new secretsmanager.Secret(this, 'DbSecret', {
  secretName: `/platform/${env}/db`,
  generateSecretString: {
    secretStringTemplate: JSON.stringify({ username: 'app' }),
    generateStringKey: 'password',
    excludeCharacters: '"@/\\\'',
    passwordLength: 40,
  },
});

const cluster = new rds.DatabaseCluster(this, 'Db', {
  credentials: rds.Credentials.fromSecret(dbSecret),
  // ...
});

The template contains the secret's resource, with instructions for generating a value. It doesn't contain the value. CloudFormation creates it, passes it to RDS through a dynamic reference ({{resolve:secretsmanager:...}}) that's resolved inside CloudFormation and never appears in the stored template, and that's the last time anything outside Secrets Manager and Aurora touches it. Rotation, when you turn it on, works the same way; we cover that separately.

Pattern 2: the secret a human enters once, by reference

Third-party API keys arrive from a vendor's dashboard and a human has to put them somewhere. That somewhere is the CLI, once per account, and never the code:

aws secretsmanager create-secret --name /platform/prod/payments \
  --secret-string '{"apiKey":"...","webhookSecret":"..."}' --profile prod

The CDK side references it by name and grants the reader:

const payments = secretsmanager.Secret.fromSecretNameV2(this, 'Payments', `/platform/${env}/payments`);
payments.grantRead(apiService.instanceRole);

The template contains the name. Not the value, not even a placeholder. If the secret doesn't exist in an account, the service fails to start with a clear error, which is the correct behaviour for "someone forgot to set up the new account". We keep a secrets.md in the repo that lists every secret name and what shape its JSON has, so the person setting up a new account has a checklist and the code has a single source of truth for keys.

Pattern 3: getting the value into the process

Three consumers, three mechanisms, none of which involve the template.

App Runner has runtimeEnvironmentSecrets: a map of environment variable name to secret ARN plus JSON key. The service fetches the value at instance start and injects it as a plain environment variable inside the container. The instance role needs secretsmanager:GetSecretValue on exactly those ARNs, which grantRead gives it.

runtimeEnvironmentSecrets: {
  DB_PASSWORD: apprunner.Secret.fromSecretsManager(dbSecret, 'password'),
  PAYMENTS_API_KEY: apprunner.Secret.fromSecretsManager(payments, 'apiKey'),
  PREVIEW_TOKEN: apprunner.Secret.fromSsmParameter(previewToken),
},

Lambda has no equivalent injection, so it fetches at cold start. The AWS Parameters and Secrets Lambda Extension runs as a layer, serves a local HTTP endpoint, caches for a configurable TTL, and means the function's code makes one localhost call instead of an SDK call. Ten lines in the handler's init, and a rotation propagates within the cache TTL without a redeploy.

CodeBuild reads Parameter Store and Secrets Manager directly in the buildspec's env.secrets-manager and env.parameter-store blocks, so a build can have the registry token without the token being in the project definition. The build role gets the grant. The value is masked in the build log, which we learned to double-check after one appeared anyway through an echo.

Secrets Manager · Parameter Storethe only place values exist App RunnerruntimeEnvironmentSecretsfetched at instance start LambdaParameters & Secrets extensionlocalhost, cached, TTL CodeBuildbuildspec env.secrets-managermasked in logs each gets GetSecretValue on specific ARNs · granted in the same template that names the secret · value never in the template

The three ways it goes wrong anyway

SecretValue.unsafePlainText. CDK makes you type the word "unsafe" to put a literal secret in a template, and people still do it, usually in a test stack that later becomes a real one. Our CI grep catches it. Ban it in the linter too.

Secrets in cdk.context.json. Context values are committed to git by design, so they're the wrong place for anything sensitive. We've seen an API key end up there via --context apiKey=... on the command line. Context is for account IDs, VPC lookups and feature toggles, nothing else.

Reading a secret in the CDK app itself. secretsmanager.Secret.fromSecretNameV2(...).secretValue.unsafeUnwrap() at synth time resolves the value on the developer's machine and writes it into the template. It's the same leak with more steps. The only correct use of secretValue is passing it to a construct that knows how to turn it into a dynamic reference, which is what Credentials.fromSecret and runtimeEnvironmentSecrets do.

What it looks like across three accounts

Same code, three accounts, three sets of values, zero values in the repository:

Secret Lives in Created by Read by
Database credentials Secrets Manager CloudFormation (generated) App Runner, migration Lambda, RDS Proxy
Third-party API keys (3) Secrets Manager, one JSON each A human, once per account, via CLI App Runner, webhook Lambdas
Web push signing key Secrets Manager A human, once Notification Lambda
Preview token, feature flags with values, internal URLs Parameter Store A human, once, or CDK for non-sensitive ones App Runner, Lambdas
Container registry token for builds Secrets Manager CloudFormation (generated) CodeBuild

Nothing in that table is in a template, a .env file in git, a GitHub secret, or a laptop's shell history, and the deploy role can't read any of it, because it doesn't need to: CloudFormation and the consuming services do the fetching, with their own roles.

If your templates or your .env files have values in them and you'd like them not to, we've done this migration before; it's about a day per account.