# 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](/en/blog/aws-three-accounts-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:

```bash
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](/en/blog/aws-bill-of-a-three-person-startup). 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.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Decision flow for where a secret lives. Does it rotate, need generating, or get read from another account? Yes: Secrets Manager, as a JSON secret grouping related keys. No: SSM Parameter Store SecureString, free. Both are referenced from the CDK template by name only, and the consuming service fetches the value at runtime with an IAM grant. A red box marks the forbidden path: a literal in environment variables in the template.">
<defs><marker id="arrS" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7b8cff"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="70" width="220" height="80" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="130" y="96" text-anchor="middle" fill="#f1f3ff" font-weight="700">a value the app needs</text><text x="130" y="116" text-anchor="middle" fill="#9aa3c7" font-size="11">rotates? generated?</text><text x="130" y="132" text-anchor="middle" fill="#9aa3c7" font-size="11">read from another account?</text>
<line x1="242" y1="90" x2="318" y2="60" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrS)"/><text x="280" y="66" text-anchor="middle" fill="#9aa3c7" font-size="10">yes</text>
<line x1="242" y1="130" x2="318" y2="160" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrS)"/><text x="280" y="160" text-anchor="middle" fill="#9aa3c7" font-size="10">no</text>
<rect x="320" y="30" width="240" height="64" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="440" y="54" text-anchor="middle" fill="#4fffb0" font-weight="700">Secrets Manager</text><text x="440" y="74" text-anchor="middle" fill="#9aa3c7" font-size="11">JSON secret · $0.40 / month · rotation</text>
<rect x="320" y="130" width="240" height="64" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="440" y="154" text-anchor="middle" fill="#ffd166" font-weight="700">SSM Parameter Store</text><text x="440" y="174" text-anchor="middle" fill="#9aa3c7" font-size="11">SecureString · free · versioned</text>
<line x1="562" y1="62" x2="638" y2="100" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrS)"/>
<line x1="562" y1="162" x2="638" y2="124" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrS)"/>
<rect x="640" y="80" width="240" height="64" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="760" y="104" text-anchor="middle" fill="#f1f3ff" font-weight="700">template: name or ARN only</text><text x="760" y="124" text-anchor="middle" fill="#9aa3c7" font-size="11">consumer fetches at runtime · IAM grant</text>
<rect x="320" y="210" width="240" height="32" rx="8" fill="#2b1522" stroke="#ff6b8a" stroke-width="1.5"/><text x="440" y="231" text-anchor="middle" fill="#ff6b8a" font-size="11">environment: { KEY: 'literal' } → in the template → leak</text>
</g>
</svg>
</div>

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

```ts
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](/en/blog/rotating-the-database-password-without-downtime).

## 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:

```bash
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:

```ts
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.

```ts
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](/en/blog/cloudwatch-data-protection-policies-pii) after one appeared anyway through an `echo`.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three consumers fetching secrets at runtime. App Runner: runtimeEnvironmentSecrets maps variable names to secret ARNs, fetched at instance start. Lambda: the Parameters and Secrets extension serves a local cached endpoint at cold start. CodeBuild: buildspec env secrets-manager block, masked in logs. All three are granted GetSecretValue on specific ARNs in the same CDK template that references the secrets by name.">
<defs><marker id="arrS2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="330" y="20" width="240" height="56" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="450" y="44" text-anchor="middle" fill="#4fffb0" font-weight="700">Secrets Manager · Parameter Store</text><text x="450" y="64" text-anchor="middle" fill="#9aa3c7" font-size="11">the only place values exist</text>
<line x1="380" y1="78" x2="150" y2="130" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrS2)"/>
<line x1="450" y1="78" x2="450" y2="130" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrS2)"/>
<line x1="520" y1="78" x2="750" y2="130" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrS2)"/>
<rect x="30" y="132" width="240" height="70" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="150" y="154" text-anchor="middle" fill="#f1f3ff" font-weight="700">App Runner</text><text x="150" y="172" text-anchor="middle" fill="#9aa3c7" font-size="11">runtimeEnvironmentSecrets</text><text x="150" y="190" text-anchor="middle" fill="#9aa3c7" font-size="11">fetched at instance start</text>
<rect x="330" y="132" width="240" height="70" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="450" y="154" text-anchor="middle" fill="#f1f3ff" font-weight="700">Lambda</text><text x="450" y="172" text-anchor="middle" fill="#9aa3c7" font-size="11">Parameters &amp; Secrets extension</text><text x="450" y="190" text-anchor="middle" fill="#9aa3c7" font-size="11">localhost, cached, TTL</text>
<rect x="630" y="132" width="240" height="70" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="750" y="154" text-anchor="middle" fill="#f1f3ff" font-weight="700">CodeBuild</text><text x="750" y="172" text-anchor="middle" fill="#9aa3c7" font-size="11">buildspec env.secrets-manager</text><text x="750" y="190" text-anchor="middle" fill="#9aa3c7" font-size="11">masked in logs</text>
<text x="450" y="224" text-anchor="middle" fill="#9aa3c7">each gets GetSecretValue on specific ARNs · granted in the same template that names the secret · value never in the template</text>
</g>
</svg>
</div>

## 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](/en/blog/github-oidc-deploy-roles-per-aws-account) 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](/contact); it's about a day per account.
