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 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 :
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. 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.
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.
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.
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 :
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 :
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.
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 après qu'une est apparue quand même via un echo.
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 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 ; c'est environ une journée par compte.