# GitHub OIDC statt Access Keys: eine Deploy-Rolle pro AWS-Konto, null Secrets in CI

In den Repository-Secrets Ihres GitHub-Projekts liegt ein langlebiger AWS Access Key. Angelegt hat ihn, wer die erste Pipeline aufgesetzt hat, er hat `AdministratorAccess`, weil das der schnellste Weg war, den Deploy durchzubekommen, und rotiert hat ihn seitdem niemand. Wenn dieser Satz Ihr Setup nicht beschreibt, gehören Sie zur Minderheit und können zum Abschnitt über die Trust Policy springen. Wenn doch, dann erzählt dieser Artikel, wie wir ihn an einem Nachmittag über drei Konten hinweg durch etwas ersetzt haben, das keinen Schlüssel hat, der leaken könnte.

## Was OIDC tatsächlich ändert

GitHub Actions kann für jeden Workflow-Lauf ein kurzlebiges Identitätstoken prägen. Das Token ist von GitHub signiert und trägt Claims darüber, woher es kommt: Repository, Branch oder Tag, Environment, Workflow-Datei, Akteur. AWS IAM kann angewiesen werden, GitHubs Token-Aussteller zu vertrauen, und eine IAM-Rolle kann so konfiguriert werden, dass sie nur Tokens akzeptiert, deren Claims einer Bedingung entsprechen. Der Workflow tauscht sein Token gegen temporäre AWS-Credentials, die eine Stunde leben und nirgends gespeichert werden.

Drei Konsequenzen. Es gibt kein Secret in GitHub, also nichts, was leaken oder rotiert werden könnte. Die Deploy-Berechtigung ist daran gebunden, *woher der Code kommt*, nicht daran, wer den Schlüssel hat, sodass ein Fork oder ein beliebiger Branch nicht nach Produktion deployen kann, selbst wenn er dieselbe Workflow-Datei ausführt. Und jede angenommene Session taucht in CloudTrail mit Repository und Branch im Session-Namen auf, sodass „wer hat das deployt?“ eine Antwort hat.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Ablauf eines OIDC-Deploys. Ein GitHub-Actions-Job auf dem main-Branch fordert ein Identitätstoken von GitHub an. Er ruft AWS STS AssumeRoleWithWebIdentity mit diesem Token auf. IAM prüft die Trust Policy: Aussteller ist GitHub, Audience ist sts.amazonaws.com, Subject entspricht repo org/platform ref refs/heads/main. STS gibt Ein-Stunden-Credentials für die Rolle github-deploy-prod zurück, die der Job für cdk deploy nutzt. Nichts wird gespeichert.">
<defs><marker id="arrO" 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="20" y="60" width="200" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="88" text-anchor="middle" fill="#f1f3ff" font-weight="700">GitHub-Actions-Job</text><text x="120" y="108" text-anchor="middle" fill="#9aa3c7">repo org/platform · main</text>
<line x1="222" y1="80" x2="338" y2="80" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrO)"/><text x="280" y="70" text-anchor="middle" fill="#9aa3c7">1 · ID-Token (von GitHub signiert)</text>
<line x1="338" y1="110" x2="222" y2="110" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrO)"/><text x="280" y="128" text-anchor="middle" fill="#9aa3c7">4 · Credentials für 1 Stunde</text>
<rect x="340" y="60" width="220" height="70" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="450" y="88" text-anchor="middle" fill="#f1f3ff" font-weight="700">AWS STS</text><text x="450" y="108" text-anchor="middle" fill="#9aa3c7">AssumeRoleWithWebIdentity</text>
<line x1="562" y1="80" x2="678" y2="80" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrO)"/><text x="620" y="70" text-anchor="middle" fill="#9aa3c7">2 · Trust Policy prüfen</text>
<line x1="678" y1="110" x2="562" y2="110" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrO)"/><text x="620" y="128" text-anchor="middle" fill="#9aa3c7">3 · sub passt → allow</text>
<rect x="680" y="60" width="200" height="70" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="780" y="88" text-anchor="middle" fill="#f1f3ff" font-weight="700">IAM-Rolle</text><text x="780" y="108" text-anchor="middle" fill="#ffd166">github-deploy-prod</text>
<text x="450" y="170" text-anchor="middle" fill="#9aa3c7">trust: iss = token.actions.githubusercontent.com · aud = sts.amazonaws.com · sub = repo:org/platform:ref:refs/heads/main</text>
<text x="450" y="196" text-anchor="middle" fill="#4fffb0">kein Access Key existiert · nichts zu rotieren · CloudTrail zeigt Repo und Ref im Session-Namen</text>
<text x="450" y="218" text-anchor="middle" fill="#ff6b8a">ein Fork, ein Feature-Branch oder ein anderes Repo bekommt in Schritt 3 AccessDenied</text>
</g>
</svg>
</div>

## Ein Provider, drei Rollen, drei Konten

Der OIDC-Provider ist eine Ressource pro Konto mit GitHubs Aussteller-URL und Thumbprint. Wir legen je einen in Dev, Staging und Prod an, aus derselben CDK-Codebasis, die alles andere erzeugt, und daneben eine Deploy-Rolle. Die Trust Policy der Rolle ist der Ort, an dem die Umgebungstrennung lebt:

```ts
// infra/lib/github-deploy-role.ts
const provider = new iam.OpenIdConnectProvider(this, 'GitHubOidc', {
  url: 'https://token.actions.githubusercontent.com',
  clientIds: ['sts.amazonaws.com'],
});

const allowedSubjects: Record<Env, string[]> = {
  dev:     ['repo:org/platform:pull_request', 'repo:org/platform:ref:refs/heads/*'],
  staging: ['repo:org/platform:ref:refs/heads/main'],
  prod:    ['repo:org/platform:environment:production'],
};

new iam.Role(this, 'GitHubDeployRole', {
  roleName: `github-deploy-${env}`,
  maxSessionDuration: Duration.hours(1),
  assumedBy: new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
    StringEquals: { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com' },
    StringLike:   { 'token.actions.githubusercontent.com:sub': allowedSubjects[env] },
  }),
});
```

Lesen Sie die `allowedSubjects`-Map als die Policy, die sie ist. Jeder Branch und jeder Pull Request darf nach **Dev** deployen, genau das, was Preview-Umgebungen brauchen. Nur der `main`-Branch darf nach **Staging** deployen. Nur ein Job, der im GitHub-Environment namens `production` läuft, darf nach **Prod** deployen, und dieses Environment hat in GitHub Required Reviewers konfiguriert, sodass ein Produktions-Deploy ein `main`-Build ist, den ein Mensch freigegeben hat. Der Subject-Claim eines Environment-Jobs lautet unabhängig vom Branch `repo:org/platform:environment:production`, also kombinieren wir ihn mit einer Branch-Protection-Regel, nach der nur `main` in dieses Environment deployen darf.

Zwei Details, die uns Zeit gekostet haben. Die `aud`-Bedingung muss `StringEquals` sein, nicht `StringLike`, sonst beschwert sich ein Linter zu Recht, dass jede Audience akzeptiert wird. Und das Format des `sub`-Claims ändert sich mit dem Trigger: `ref:refs/heads/main` bei einem Push, `pull_request` bei einem PR, `environment:name` bei einem Environment-Job. Wenn ein Workflow beim Assume `AccessDenied` bekommt, geben Sie die Token-Claims mit `actions/github-script` aus, bevor Sie IAM anfassen; neun von zehn Mal ist es das Subject-Format.

## Was die Rolle tatsächlich darf

Die Trust Policy sagt, wer die Rolle annehmen darf. Die Permission Policy sagt, was sie danach tun darf, und hier hört „Least Privilege für eine Deploy-Rolle“ auf, ein Slogan zu sein. Unser Deploy führt `cdk deploy` aus, und CDKs Modell macht die Antwort sauber: die Deploy-Rolle braucht keine Berechtigung, App-Runner-Services oder DynamoDB-Tabellen zu erstellen. Sie braucht die Berechtigung, CloudFormation ein Template zu übergeben und CloudFormations eigene Ausführungsrolle die Arbeit machen zu lassen.

```ts
role.addToPolicy(new iam.PolicyStatement({
  sid: 'AssumeCdkRoles',
  actions: ['sts:AssumeRole'],
  resources: [
    `arn:aws:iam::${account}:role/cdk-hnb659fds-deploy-role-${account}-${region}`,
    `arn:aws:iam::${account}:role/cdk-hnb659fds-file-publishing-role-${account}-${region}`,
    `arn:aws:iam::${account}:role/cdk-hnb659fds-image-publishing-role-${account}-${region}`,
    `arn:aws:iam::${account}:role/cdk-hnb659fds-lookup-role-${account}-${region}`,
  ],
}));
role.addToPolicy(new iam.PolicyStatement({
  sid: 'BuildImages',
  actions: ['codebuild:StartBuild', 'codebuild:BatchGetBuilds'],
  resources: [`arn:aws:codebuild:${region}:${account}:project/platform-*`],
}));
```

Das ist die ganze Policy. Vier `sts:AssumeRole`-Statements auf die Rollen, die CDK beim Bootstrap angelegt hat, plus die Berechtigung, unsere CodeBuild-Projekte zu starten. Die CloudFormation-Ausführungsrolle, die CDK beim Bootstrap erzeugt hat, ist die mit den weitreichenden Rechten, und sie kann nur von CloudFormation verwendet werden, das sich nur über ein Template steuern lässt, das durch ein Code-Review gegangen ist. Die GitHub-Rolle selbst kann `apprunner:DeleteService` nicht aufrufen. Sie kann nicht einmal Buckets auflisten. Als wir nach einem Monat den Unused-Access-Report des IAM Access Analyzer laufen ließen, hatten die Deploy-Rollen null ungenutzte Berechtigungen, ein Satz, den wir über ein CI-Credential noch nie sagen konnten.

Die Image-Publishing-Rolle verdient einen Hinweis: wenn Ihre Images außerhalb von CodeBuild gebaut werden, auf dem GitHub-Runner selbst, braucht die Deploy-Rolle `ecr:GetAuthorizationToken` und Push-Rechte auf die Repositories. Wir haben die Image-Builds nach CodeBuild verlegt, teils um das von der GitHub-Rolle fernzuhalten, teils weil [ein 2-vCPU-Runner, der drei Next.js-Images baut, langsam ist](/de/blog/monorepo-three-apps-build-only-what-changed).

## Der Workflow

```yaml
# .github/workflows/deploy-prod.yml
on:
  workflow_dispatch:
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production          # hier leben die Required Reviewers
    permissions:
      id-token: write                # das aktiviert OIDC
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<prod-account-id>:role/github-deploy-prod
          role-session-name: gh-${{ github.run_id }}-${{ github.actor }}
          aws-region: us-east-1
      - run: ./scripts/build-images.sh --tag ${{ github.sha }}
      - run: npx cdk deploy platform-prod --require-approval never
```

Kein `AWS_ACCESS_KEY_ID`, kein `AWS_SECRET_ACCESS_KEY`, gar kein Secrets-Block. Die Konto-ID im Rollen-ARN ist nicht sensibel; Konto-IDs stehen in jedem ARN in jeder Log-Zeile. Der `role-session-name` bringt Lauf und Akteur nach CloudTrail, was wir genau einmal gebraucht haben: um zu bestätigen, dass ein Deploy, an den sich niemand erinnerte, ein geplanter Workflow war und keine Person.

## Was aus dem alten Schlüssel wurde

Wir haben ihn gelöscht. Nicht „eine Weile deaktiviert, falls etwas kaputtgeht“; wir haben die neue Pipeline eine Woche in Dev laufen lassen, einen Deploy in Staging, einen in Prod, dann den IAM-User gelöscht. Etwas ging tatsächlich kaputt: ein Terraform-Modul in einem anderen Repository, von jemand anderem gepflegt, das denselben Schlüssel kopiert hatte. Es scheiterte laut, und genau das ist der Punkt. Zwei Tage später bekam es seine eigene Rolle.

Das GitHub-Secret wurde in derselben Änderung gelöscht. Secrets in GitHub sind über die UI write-only, aber jeder Workflow im Repository kann sie lesen, auch einer, den ein Mitwirkender in einem Pull Request hinzufügt, sodass ein Secret, das Sie nicht brauchen, ein Secret ist, das irgendwann in einem Log landet.

## Die Checkliste

- Ein OIDC-Provider pro Konto, aus Infrastrukturcode erzeugt.
- Eine Rolle pro Konto, nach ihrer Umgebung benannt, mit einer Trust Policy, die das Repository und den Ref oder das Environment nennt, die sie annehmen dürfen. Wildcards auf dem Branch nur in Dev.
- `aud` unter `StringEquals`. `sub` unter `StringLike` nur, wenn Sie wirklich ein Wildcard brauchen.
- Permission Policy: die CDK-Bootstrap-Rollen annehmen, die Build-Projekte starten, sonst nichts. Ohne CDK ist das Äquivalent `cloudformation:*` auf Ihren Stacks plus `iam:PassRole` für die Ausführungsrolle.
- Sessions von einer Stunde. Ein Deploy, der länger braucht, hat ein anderes Problem.
- `role-session-name` mit Lauf-ID und Akteur.
- Access Key löschen. IAM-User löschen. GitHub-Secret löschen. Beobachten, was kaputtgeht; das ist Ihr Inventar der Dinge, die sich den Schlüssel geteilt haben.

Die ganze Änderung umfasste unter 150 Zeilen CDK und 20 Zeilen YAML pro Workflow. Sie hat das wertvollste Secret des Unternehmens aus dem exponiertesten Ort entfernt, an dem es leben konnte. Wenn in Ihrer Pipeline noch ein Access Key steckt, [helfen wir Ihnen, ihn herauszunehmen](/contact).
