# Secrets in CDK: Secrets Manager, Parameter Store, und niemals etwas im Template

Ein CloudFormation-Template ist eine Textdatei. Es wird von CloudFormation gespeichert, liegt in `cdk.out` auf der Festplatte jedes Entwicklers, steht in den CI-Logs des letzten Synth, und wenn jemand `cdk.out` versehentlich committet, ist es für immer in Git. Alles, was im Template als Literal steht, ist praktisch innerhalb der Organisation öffentlich. Dazu gehört das Datenbankpasswort, das Sie mit `environment: { DB_PASSWORD: '...' }` gesetzt haben, weil es schneller ging, als es richtig zu machen.

Wir betreiben [drei Konten aus einer CDK-Codebasis](/de/blog/aws-three-accounts-one-cdk-codebase) mit etwa fünfundzwanzig Secrets pro Konto, und keines davon war je in einem Template. Das hier ist der Regelsatz, der das wahr macht, die beiden beteiligten AWS-Dienste, wann man welchen nutzt, und die Muster, mit denen ein Wert in einen Container, eine Lambda oder einen Build gelangt, ohne dass ein Mensch ihn je irgendwo einfügt.

## Die Regel

**Das Template trägt Referenzen, nie Werte.** Eine Referenz ist ein ARN, ein Name oder ein Parameterpfad. Der Wert lebt an einem von zwei Orten, Secrets Manager oder SSM Parameter Store, und wird zur Laufzeit von dem geholt, was ihn braucht, mit einer IAM-Berechtigung, die im selben Template erteilt wird. Wenn Sie ein Secret aus `cdk.out` greppen können, ist es ein Leck, und CDK macht die Prüfung leicht:

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

Das läuft bei uns in CI. Es ist zweimal fehlgeschlagen, beide Male an einem gut gemeinten `environment:`-Eintrag in einer Lambda.

## Die beiden Dienste, und wann welcher

| | Secrets Manager | SSM Parameter Store (SecureString) |
|---|---|---|
| Preis | $0,40 pro Secret und Monat + $0,05 pro 10.000 Aufrufe | Kostenlos in der Standardstufe (4 KB, 10.000 Parameter); $0,05 pro erweitertem Parameter |
| Rotation | Eingebaut, mit Lambda-Rotationsfunktionen für RDS, Aurora, Redshift und eigene | Keine; Sie rotieren, indem Sie eine neue Version schreiben |
| Generierung | Kann den Wert selbst erzeugen (`generateSecretString`) | Nein |
| Kontoübergreifend | Ressourcen-Policy erlaubt einem anderen Konto das Lesen | Nicht direkt |
| Native Integration | App Runner `runtimeEnvironmentSecrets`, ECS `secrets`, Lambda-Extension, RDS Proxy | ECS `secrets`, Lambda-Extension, CodeBuild `parameter-store` |
| Versionierung | Staging-Labels (AWSCURRENT / AWSPREVIOUS) | Nummerierte Versionen |

Die Regel, auf die wir uns geeinigt haben: **Secrets Manager für alles, was rotiert, generiert wird oder kontoübergreifend gelesen wird. Parameter Store für alles andere.** In der Praxis landen damit die Datenbank-Zugangsdaten, der Web-Push-Signaturschlüssel und die Drittanbieter-Tokens, die wir planmäßig rotieren, im Secrets Manager, und die lange Liste konfigurationsartiger Secrets (Feature-Flags mit sensiblen Werten, ein geteiltes Preview-Token, API-Basis-URLs pro Umgebung mit eingebetteten Schlüsseln) im Parameter Store.

Der Kostenunterschied ist größer, als er aussieht. Bei $0,40 pro Secret sind fünfundzwanzig Secrets in drei Konten $30 im Monat, was [4 % unserer Rechnung waren](/de/blog/aws-bill-of-a-three-person-startup). Zwei Schritte haben das halbiert: zusammengehörige Werte in ein JSON-Secret gruppieren statt ein Secret pro Wert, und die konfigurationsartigen in den Parameter Store verschieben, der kostenlos ist.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Entscheidungsfluss, wo ein Secret lebt. Rotiert es, muss es generiert werden oder wird es aus einem anderen Konto gelesen? Ja: Secrets Manager, als JSON-Secret, das zusammengehörige Schlüssel gruppiert. Nein: SSM Parameter Store SecureString, kostenlos. Beide werden aus dem CDK-Template nur per Name referenziert, und der konsumierende Dienst holt den Wert zur Laufzeit mit einer IAM-Berechtigung. Ein roter Kasten markiert den verbotenen Weg: ein Literal in Umgebungsvariablen im 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">ein Wert, den die App braucht</text><text x="130" y="116" text-anchor="middle" fill="#9aa3c7" font-size="11">rotiert? generiert?</text><text x="130" y="132" text-anchor="middle" fill="#9aa3c7" font-size="11">aus anderem Konto gelesen?</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">ja</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">nein</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 / Monat · 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 · kostenlos · versioniert</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: nur Name oder ARN</text><text x="760" y="124" text-anchor="middle" fill="#9aa3c7" font-size="11">Konsument holt zur Laufzeit · 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' } → im Template → Leck</text>
</g>
</svg>
</div>

## Muster 1: das Secret, das CloudFormation erzeugt und das niemand je sieht

Das beste Secret ist eines, das kein Mensch je gelesen hat. Das Datenbankpasswort ist der klassische Fall: CDK bittet Secrets Manager, es zu erzeugen, Aurora wird angewiesen, es zu verwenden, und der Wert existiert nur im Secrets Manager und in der Datenbank.

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

Das Template enthält die *Ressource* des Secrets mit Anweisungen zur Erzeugung eines Werts. Es enthält den Wert nicht. CloudFormation erzeugt ihn, übergibt ihn an RDS über eine dynamische Referenz (`{{resolve:secretsmanager:...}}`), die innerhalb von CloudFormation aufgelöst wird und nie im gespeicherten Template erscheint, und das ist das letzte Mal, dass etwas außerhalb von Secrets Manager und Aurora ihn berührt. Rotation funktioniert, wenn Sie sie einschalten, genauso; [das behandeln wir separat](/de/blog/rotating-the-database-password-without-downtime).

## Muster 2: das Secret, das ein Mensch einmal einträgt, per Referenz

Drittanbieter-API-Schlüssel kommen aus dem Dashboard eines Anbieters, und ein Mensch muss sie irgendwo ablegen. Dieses Irgendwo ist die CLI, einmal pro Konto, und nie der Code:

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

Die CDK-Seite referenziert es per Name und erteilt dem Leser die Berechtigung:

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

Das Template enthält den Namen. Nicht den Wert, nicht einmal einen Platzhalter. Existiert das Secret in einem Konto nicht, startet der Dienst mit einer klaren Fehlermeldung nicht, was das korrekte Verhalten für „jemand hat vergessen, das neue Konto einzurichten“ ist. Wir halten eine `secrets.md` im Repo, die jeden Secret-Namen und die Form seines JSON auflistet, damit die Person, die ein neues Konto einrichtet, eine Checkliste hat und der Code eine einzige Wahrheitsquelle für Schlüssel.

## Muster 3: den Wert in den Prozess bekommen

Drei Konsumenten, drei Mechanismen, keiner davon berührt das Template.

**App Runner** hat `runtimeEnvironmentSecrets`: eine Zuordnung von Umgebungsvariablenname zu Secret-ARN plus JSON-Schlüssel. Der Dienst holt den Wert beim Instanzstart und injiziert ihn als gewöhnliche Umgebungsvariable in den Container. Die Instanzrolle braucht `secretsmanager:GetSecretValue` genau auf diesen ARNs, was `grantRead` liefert.

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

**Lambda** hat keine entsprechende Injektion, also holt sie beim Kaltstart. Die AWS Parameters and Secrets Lambda Extension läuft als Layer, bedient einen lokalen HTTP-Endpunkt, cacht für eine konfigurierbare TTL und bedeutet, dass der Code der Funktion einen localhost-Aufruf statt eines SDK-Aufrufs macht. Zehn Zeilen im Init des Handlers, und eine Rotation propagiert sich innerhalb der Cache-TTL ohne Redeploy.

**CodeBuild** liest Parameter Store und Secrets Manager direkt in den Blöcken `env.secrets-manager` und `env.parameter-store` der Buildspec, sodass ein Build das Registry-Token haben kann, ohne dass das Token in der Projektdefinition steht. Die Build-Rolle bekommt den Grant. Der Wert wird im Build-Log maskiert, was wir [doppelt zu prüfen gelernt haben](/de/blog/cloudwatch-data-protection-policies-pii), nachdem einer trotzdem über ein `echo` auftauchte.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Drei Konsumenten, die Secrets zur Laufzeit holen. App Runner: runtimeEnvironmentSecrets ordnet Variablennamen Secret-ARNs zu, beim Instanzstart geholt. Lambda: die Parameters-and-Secrets-Extension bedient beim Kaltstart einen lokalen gecachten Endpunkt. CodeBuild: Buildspec-Block env secrets-manager, in Logs maskiert. Alle drei erhalten GetSecretValue auf bestimmte ARNs im selben CDK-Template, das die Secrets per Name referenziert.">
<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">der einzige Ort, an dem Werte existieren</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">beim Instanzstart geholt</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, gecacht, 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">in Logs maskiert</text>
<text x="450" y="224" text-anchor="middle" fill="#9aa3c7">jeder bekommt GetSecretValue auf bestimmte ARNs · erteilt im selben Template, das das Secret benennt · Wert nie im Template</text>
</g>
</svg>
</div>

## Die drei Wege, auf denen es trotzdem schiefgeht

**`SecretValue.unsafePlainText`.** CDK zwingt Sie, das Wort „unsafe“ zu tippen, um ein Literal-Secret in ein Template zu setzen, und Leute tun es trotzdem, meist in einem Test-Stack, der später ein echter wird. Unser CI-Grep fängt es. Verbieten Sie es auch im Linter.

**Secrets in `cdk.context.json`.** Kontextwerte werden per Design in Git committet, also sind sie der falsche Ort für alles Sensible. Wir haben gesehen, wie ein API-Schlüssel per `--context apiKey=...` auf der Kommandozeile dort landete. Kontext ist für Konto-IDs, VPC-Lookups und Feature-Toggles, sonst nichts.

**Ein Secret in der CDK-App selbst lesen.** `secretsmanager.Secret.fromSecretNameV2(...).secretValue.unsafeUnwrap()` zur Synth-Zeit löst den Wert auf dem Entwicklerrechner auf und schreibt ihn ins Template. Dasselbe Leck mit mehr Schritten. Die einzige korrekte Verwendung von `secretValue` ist die Übergabe an ein Construct, das weiß, wie man daraus eine dynamische Referenz macht, was `Credentials.fromSecret` und `runtimeEnvironmentSecrets` tun.

## Wie es über drei Konten aussieht

Derselbe Code, drei Konten, drei Wertesätze, null Werte im Repository:

| Secret | Lebt in | Erstellt von | Gelesen von |
|---|---|---|---|
| Datenbank-Zugangsdaten | Secrets Manager | CloudFormation (generiert) | App Runner, Migrations-Lambda, RDS Proxy |
| Drittanbieter-API-Schlüssel (3) | Secrets Manager, je ein JSON | Ein Mensch, einmal pro Konto, per CLI | App Runner, Webhook-Lambdas |
| Web-Push-Signaturschlüssel | Secrets Manager | Ein Mensch, einmal | Benachrichtigungs-Lambda |
| Preview-Token, Feature-Flags mit Werten, interne URLs | Parameter Store | Ein Mensch, einmal, oder CDK für Unsensibles | App Runner, Lambdas |
| Registry-Token für Builds | Secrets Manager | CloudFormation (generiert) | CodeBuild |

Nichts in dieser Tabelle steht in einem Template, einer `.env`-Datei in Git, einem GitHub-Secret oder der Shell-Historie eines Laptops, und die [Deploy-Rolle](/de/blog/github-oidc-deploy-roles-per-aws-account) kann nichts davon lesen, weil sie es nicht muss: CloudFormation und die konsumierenden Dienste holen, mit ihren eigenen Rollen.

Wenn Ihre Templates oder Ihre `.env`-Dateien Werte enthalten und Sie das ändern möchten, [wir haben diese Migration schon gemacht](/contact); etwa ein Tag pro Konto.
