# Les secrets en CDK : Secrets Manager, Parameter Store, et jamais rien dans le template

Un template CloudFormation est un fichier texte. Il est stocké par CloudFormation, il est dans `cdk.out` sur le disque de chaque développeur, il est dans les logs de CI du dernier synth, et si quelqu'un commite `cdk.out` par accident, il est dans git pour toujours. Tout ce qui apparaît dans le template comme valeur littérale est, en pratique, public au sein de l'organisation. Cela inclut le mot de passe de base de données que vous avez mis avec `environment: { DB_PASSWORD: '...' }` parce que c'était plus rapide que de le faire proprement.

Nous faisons tourner [trois comptes depuis une seule base de code CDK](/fr/blog/aws-three-accounts-one-cdk-codebase) avec environ vingt-cinq secrets par compte, et aucun n'a jamais été dans un template. Voici l'ensemble de règles qui rend cela vrai, les deux services AWS impliqués, quand utiliser lequel, et les patterns pour amener une valeur dans un conteneur, une Lambda ou un build sans qu'un humain ne la colle jamais nulle part.

## La règle

**Le template porte des références, jamais des valeurs.** Une référence, c'est un ARN, un nom ou un chemin de paramètre. La valeur vit à l'un de deux endroits, Secrets Manager ou SSM Parameter Store, et elle est récupérée à l'exécution par la chose qui en a besoin, avec une permission IAM accordée dans le même template. Si vous pouvez extraire un secret de `cdk.out` avec `grep`, c'est une fuite, et CDK rend la vérification facile :

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

Nous lançons ça en CI. Ça a échoué deux fois, les deux sur une entrée `environment:` bien intentionnée dans une Lambda.

## Les deux services, et quand utiliser lequel

| | Secrets Manager | SSM Parameter Store (SecureString) |
|---|---|---|
| Prix | 0,40 $ par secret et par mois + 0,05 $ pour 10 000 appels | Gratuit au niveau standard (4 Ko, 10 000 paramètres) ; 0,05 $ par paramètre avancé |
| Rotation | Intégrée, avec des fonctions Lambda de rotation pour RDS, Aurora, Redshift et personnalisées | Aucune ; vous faites tourner en écrivant une nouvelle version |
| Génération | Peut générer la valeur lui-même (`generateSecretString`) | Non |
| Inter-comptes | La politique de ressource permet à un autre compte de lire | Pas directement |
| Intégration native | App Runner `runtimeEnvironmentSecrets`, ECS `secrets`, extension Lambda, RDS Proxy | ECS `secrets`, extension Lambda, CodeBuild `parameter-store` |
| Versionnage | Étiquettes de staging (AWSCURRENT / AWSPREVIOUS) | Versions numérotées |

La règle que nous avons adoptée : **Secrets Manager pour tout ce qui tourne, est généré, ou est lu depuis un autre compte. Parameter Store pour tout le reste.** En pratique, cela met les identifiants de base de données, la clé de signature web push et les jetons tiers que nous faisons tourner selon un calendrier dans Secrets Manager, et la longue liste de secrets de type configuration (feature flags avec des valeurs sensibles, un jeton de preview partagé, des URL de base d'API par environnement avec des clés intégrées) dans Parameter Store.

La différence de coût compte plus qu'il n'y paraît. À 0,40 $ le secret, vingt-cinq secrets dans trois comptes, c'est 30 $ par mois, soit [4 % de notre facture](/fr/blog/aws-bill-of-a-three-person-startup). Deux mouvements l'ont réduit de moitié : regrouper les valeurs liées dans un seul secret JSON au lieu d'un secret par valeur, et déplacer celles de type configuration vers Parameter Store, qui est gratuit.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flux de décision pour l'emplacement d'un secret. Tourne-t-il, doit-il être généré, ou est-il lu depuis un autre compte ? Oui : Secrets Manager, comme secret JSON regroupant les clés liées. Non : SSM Parameter Store SecureString, gratuit. Les deux sont référencés depuis le template CDK par nom seulement, et le service consommateur récupère la valeur à l'exécution avec une permission IAM. Une boîte rouge marque le chemin interdit : un littéral dans les variables d'environnement du 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">une valeur dont l'app a besoin</text><text x="130" y="116" text-anchor="middle" fill="#9aa3c7" font-size="11">tourne ? générée ?</text><text x="130" y="132" text-anchor="middle" fill="#9aa3c7" font-size="11">lue depuis un autre compte ?</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">oui</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">non</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">secret JSON · 0,40 $ / mois · 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 · gratuit · versionné</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 : nom ou ARN seulement</text><text x="760" y="124" text-anchor="middle" fill="#9aa3c7" font-size="11">le consommateur récupère à l'exécution · grant IAM</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' } → dans le template → fuite</text>
</g>
</svg>
</div>

## Pattern 1 : le secret que CloudFormation crée et que personne ne voit jamais

Le meilleur secret est celui qu'aucun humain n'a jamais lu. Le mot de passe de base de données est le cas canonique : CDK demande à Secrets Manager de le générer, on dit à Aurora de l'utiliser, et la valeur n'existe que dans Secrets Manager et dans la base.

```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),
  // ...
});
```

Le template contient la *ressource* du secret, avec des instructions pour générer une valeur. Il ne contient pas la valeur. CloudFormation la crée, la passe à RDS via une référence dynamique (`{{resolve:secretsmanager:...}}`) résolue à l'intérieur de CloudFormation et qui n'apparaît jamais dans le template stocké, et c'est la dernière fois que quelque chose en dehors de Secrets Manager et d'Aurora la touche. La rotation, quand vous l'activez, fonctionne de la même façon ; [nous la traitons séparément](/fr/blog/rotating-the-database-password-without-downtime).

## Pattern 2 : le secret qu'un humain saisit une fois, par référence

Les clés d'API tierces arrivent du tableau de bord d'un fournisseur et un humain doit les mettre quelque part. Ce quelque part, c'est la CLI, une fois par compte, et jamais le code :

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

Le côté CDK le référence par nom et accorde le lecteur :

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

Le template contient le nom. Pas la valeur, pas même un espace réservé. Si le secret n'existe pas dans un compte, le service échoue au démarrage avec une erreur claire, ce qui est le comportement correct pour « quelqu'un a oublié de configurer le nouveau compte ». Nous gardons un `secrets.md` dans le dépôt qui liste chaque nom de secret et la forme de son JSON, pour que la personne qui configure un nouveau compte ait une checklist et que le code ait une seule source de vérité pour les clés.

## Pattern 3 : faire entrer la valeur dans le processus

Trois consommateurs, trois mécanismes, aucun n'implique le template.

**App Runner** a `runtimeEnvironmentSecrets` : une correspondance entre nom de variable d'environnement et ARN de secret plus clé JSON. Le service récupère la valeur au démarrage de l'instance et l'injecte comme variable d'environnement ordinaire dans le conteneur. Le rôle d'instance a besoin de `secretsmanager:GetSecretValue` exactement sur ces ARN, ce que `grantRead` lui donne.

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

**Lambda** n'a pas d'injection équivalente, donc elle récupère au démarrage à froid. L'extension AWS Parameters and Secrets pour Lambda tourne comme une couche, sert un point de terminaison HTTP local, met en cache pour un TTL configurable, et signifie que le code de la fonction fait un appel localhost au lieu d'un appel de SDK. Dix lignes dans l'init du handler, et une rotation se propage dans le TTL du cache sans redéploiement.

**CodeBuild** lit Parameter Store et Secrets Manager directement dans les blocs `env.secrets-manager` et `env.parameter-store` du buildspec, donc un build peut avoir le jeton de registre sans que le jeton soit dans la définition du projet. Le rôle de build reçoit le grant. La valeur est masquée dans le log de build, ce que nous [avons appris à revérifier](/fr/blog/cloudwatch-data-protection-policies-pii) après qu'une est apparue quand même via un `echo`.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Trois consommateurs récupérant des secrets à l'exécution. App Runner : runtimeEnvironmentSecrets fait correspondre des noms de variables à des ARN de secrets, récupérés au démarrage de l'instance. Lambda : l'extension Parameters and Secrets sert un point de terminaison local mis en cache au démarrage à froid. CodeBuild : bloc env secrets-manager du buildspec, masqué dans les logs. Les trois reçoivent GetSecretValue sur des ARN précis dans le même template CDK qui référence les secrets par nom.">
<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">le seul endroit où les valeurs existent</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">récupérés au démarrage de l'instance</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">extension Parameters &amp; Secrets</text><text x="450" y="190" text-anchor="middle" fill="#9aa3c7" font-size="11">localhost, cache, 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">masqué dans les logs</text>
<text x="450" y="224" text-anchor="middle" fill="#9aa3c7">chacun reçoit GetSecretValue sur des ARN précis · accordé dans le même template qui nomme le secret · valeur jamais dans le template</text>
</g>
</svg>
</div>

## Les trois façons dont ça tourne mal quand même

**`SecretValue.unsafePlainText`.** CDK vous oblige à taper le mot « unsafe » pour mettre un secret littéral dans un template, et les gens le font quand même, généralement dans un stack de test qui devient plus tard un vrai. Notre grep de CI l'attrape. Interdisez-le aussi dans le linter.

**Des secrets dans `cdk.context.json`.** Les valeurs de contexte sont commitées dans git par conception, donc c'est le mauvais endroit pour quoi que ce soit de sensible. Nous avons vu une clé d'API y atterrir via `--context apiKey=...` en ligne de commande. Le contexte, c'est pour les identifiants de compte, les recherches de VPC et les bascules de fonctionnalités, rien d'autre.

**Lire un secret dans l'application CDK elle-même.** `secretsmanager.Secret.fromSecretNameV2(...).secretValue.unsafeUnwrap()` au moment du synth résout la valeur sur la machine du développeur et l'écrit dans le template. C'est la même fuite avec plus d'étapes. Le seul usage correct de `secretValue` est de le passer à un construct qui sait le transformer en référence dynamique, ce que font `Credentials.fromSecret` et `runtimeEnvironmentSecrets`.

## À quoi ça ressemble sur trois comptes

Même code, trois comptes, trois jeux de valeurs, zéro valeur dans le dépôt :

| Secret | Vit dans | Créé par | Lu par |
|---|---|---|---|
| Identifiants de base de données | Secrets Manager | CloudFormation (généré) | App Runner, Lambda de migration, RDS Proxy |
| Clés d'API tierces (3) | Secrets Manager, un JSON chacune | Un humain, une fois par compte, via CLI | App Runner, Lambdas de webhook |
| Clé de signature web push | Secrets Manager | Un humain, une fois | Lambda de notifications |
| Jeton de preview, feature flags avec valeurs, URL internes | Parameter Store | Un humain, une fois, ou CDK pour les non sensibles | App Runner, Lambdas |
| Jeton de registre pour les builds | Secrets Manager | CloudFormation (généré) | CodeBuild |

Rien dans ce tableau n'est dans un template, un fichier `.env` dans git, un secret GitHub ou l'historique shell d'un portable, et le [rôle de déploiement](/fr/blog/github-oidc-deploy-roles-per-aws-account) ne peut rien en lire, parce qu'il n'en a pas besoin : CloudFormation et les services consommateurs font la récupération, avec leurs propres rôles.

Si vos templates ou vos fichiers `.env` contiennent des valeurs et que vous aimeriez que ce ne soit plus le cas, [nous avons déjà fait cette migration](/contact) ; c'est environ une journée par compte.
