Secure by Design
8 min.
Fünf Secure-by-Design-Praktiken, die du heute umsetzen kannst
Die meisten Entwickler schreiben keinen unsicheren Code mit Absicht – die Leitplanken fehlten einfach. Dieser Beitrag zeigt fünf Secure-by-Design-Praktiken, die du noch im laufenden Sprint umsetzen kannst. Ohne Reorganisation, ohne großes Security-Programm.

Die Entwickler*Innen schreiben unsicheren Code nicht mit Absicht. Unsicherer Code ist meistens eine Folge von unsicheren Defaults, Error Handling als nachträglicher Gedanke oder veralteten, ungeprüften Templates für Service Account Berechtigungen. In den meisten Fällen, die uns begegnen, ist das Problem nicht, dass jemand unachtsam war. Es fehlten schlicht die richitgen Leitplanken.
In diesem Blogpost stellen wir dir fünf Praktiken vor, die du in deinem aktuellen Sprint anwenden kannst. Keine Reorganisation nötig, keine Abhängigkeit von einer Initiative des Security-Teams. Jede Praktik adressiert eine wiederkehrende Ursache für Vulnerabilities, die wir projektübergreifend beobachten.
Praktik 1: Service Accounts auf das Minimum eingrenzen
Service Accounts starten oft mit weitreichenden Berechtigungen und verlieren diese nicht wieder. Jemand richtet eine Deployment-Pipeline ein, kopiert eine IAM Policy aus einem funktionierenden Beispiel und macht weiter. Sechs Monate später hat dieser Account immer noch Schreibzugriff auf Produktionsdatenbanken, die er nie anfasst. Default Policies in Cloud-Plattformen sind weiter gefasst als nötig, und Shared Accounts über Teams hinweg machen es unmöglich nachzuvollziehen, wer was getan hat.
- Ein Account, ein Job. Wenn ein Service Account mehrere Pipelines bedient, teile ihn auf. Wird einer kompromittiert, bleibt der Blast Radius klein.
- Statische Credentials durch kurzlebige Tokens ersetzen. Hardcodierte API Keys in Umgebungsvariablen sind schnell eingerichtet und schnell vergessen. Wechsle zu Workload Identity Federation oder vergleichbaren Mechanismen, die temporäre Credentials bei Bedarf ausstellen.
- Just-in-Time-Elevation statt permanenter Admin-Rollen. Wenn ein CI/CD-Schritt für eine bestimmte Aufgabe erhöhte Privilegien benötigt, gewähre sie für genau diese Aufgabe und entziehe sie danach automatisch.
- Prüfen, was tatsächlich genutzt wird. Die meisten Cloud Provider bieten Access-Analysis-Tooling, das zeigt, welche Berechtigungen ein Account tatsächlich nutzt im Vergleich zu denen, die ihm gewährt wurden. Die Differenz ist deine Angriffsfläche.
Die IAM Policy ist die Stelle, an der das Copy-Paste-Problem am deutlichsten sichtbar wird. Hier derselbe Deployment Service Account in zwei Varianten: die Version, die meistens ausgeliefert wird, und die Version, die ausgeliefert werden sollte. Beide Policies decken dieselbe Aufgabe ab, nämlich das Aktualisieren einer bestimmten Lambda-Function-Familie und das Lesen von Deployment-Artefakten aus S3. Der Unterschied liegt darin, wie viel Vertrauen jede davon dafür einräumt.
Vorher: Offene Version
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:*",
"lambda:*",
"iam:*"
],
"Resource": "*"
}
]
}Nachher: Sauber Eingegrenzt
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DeployPaymentServiceLambdas",
"Effect": "Allow",
"Action": [
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:GetFunction",
"lambda:PublishVersion"
],
"Resource": "arn:aws:lambda:eu-central-1:123456789012:function:payment-service-*"
},
{
"Sid": "ReadDeploymentArtifacts",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::deploy-artifacts-prod/payment-service/*",
"Condition": {
"StringEquals": {
"aws:SourceVpc": "vpc-0abc1234def567890"
}
}
}
]
}Die eingegrenzte Version ersetzt Wildcard-Actions durch explizite Verben – lambda:UpdateFunctionCode, s3:GetObject – sodass der Account nur exakt das tun kann, was die Pipeline tatsächlich erfordert. Resource ARNs sind auf eine bestimmte Service-Familie beschränkt, statt accountweit zu gelten. Ein Condition Block fügt eine zweite Einschränkung hinzu: Der Call muss aus einer bekannten VPC stammen, sodass selbst gestohlene Credentials außerhalb dieser Grenze nicht funktionieren. Die Statements sind mit Sid-Identifiern aufgeteilt, was CloudTrail Logs aus einer Wand von Allow-Einträgen in etwas verwandelt, das Engineers tatsächlich einem konkreten Deployment-Schritt zuordnen können.
Praktik 2: Fail-Secure statt Fail-Open beim Error Handling
Ein Service fällt aus, und der Load Balancer leitet Traffic auf einen Fallback um, der die Authentication überspringt. Eine unbehandelte Exception schreibt einen vollständigen Stack Trace in die API Response – inklusive Datenbank-Connection-Strings. Das gemeinsame Muster: Das System hat einen unerwarteten Zustand erreicht, und der Failure Path war nicht durchdacht. Firewalls, die bei einem Crash der Rule Engine standardmäßig auf „allow” schalten, Auth-Services, die „authorized” zurückgeben, wenn sie den Identity Provider nicht erreichen können. Jeder dieser Fälle verwandelt einen temporären Ausfall in einen Security Incident.
- Default auf Deny, nicht auf Allow. Jede Access-Entscheidung sollte ein explizites „Ja” aus deiner Auth-Logik erfordern. Schlägt die Prüfung fehl, läuft sie in einen Timeout oder wirft eine Exception, lautet die Antwort „Nein”.
- Exceptions abfangen, bevor sie Informationen leaken. Verpacke nach außen sichtbare Fehlermeldungen in generische Meldungen und leite die Details in deine Logging-Pipeline. Stack Traces, interne Pfade und Tokens in API Responses oder Fehlermeldungen sind kostenlose Reconnaissance für Angreifer.
- Fallback-Konfigurationen restriktiv halten. Wenn deine Anwendung ihre Config nicht lesen kann, sollte sie in einem minimalen, gesperrten Zustand starten, statt auf permissive Defaults zurückzufallen. Fehlende Werte sollten Funktionalität deaktivieren, nicht aktivieren.
- Failure Paths testen. Lege im Staging eine Dependency lahm und beobachte, wie dein Service reagiert. Wenn du das noch nie gemacht hast, lautet die Antwort vermutlich „nicht gut”.
So sieht Fail-Secure-Error-Handling in einer Express.js-Anwendung aus. Erst der Default, der ausgeliefert wird, dann eine für die Produktion gedachte Variante.
Vorher: Default, der interne Details leakt
app.use((err, req, res, next) => {
res.status(500).send(err.stack);
});Nachher: Generische Response an den Client mit vollständigen Details in den Logs
const logger = require('./logger');
const { randomUUID } = require('crypto');
app.use((err, req, res, next) => {
const correlationId = req.correlationId || randomUUID();
const status = err.statusCode || 500;
// Everything sensitive goes to the log pipeline, never to the client
logger.error({
correlationId,
method: req.method,
path: req.path,
userId: req.user?.id,
ip: req.headers['x-forwarded-for']
err: { name: err.name, message: err.message, stack: err.stack }
}, 'Unhandled exception');
// 5xx: hide details. 4xx: only expose a message the error class marked safe.
res.status(status).json({
error: status >= 500 ? 'Internal Server Error' : ('Bad Request'),
correlationId
});
});Der überarbeitete Handler sendet niemals sensible Details an den Client. Stack Traces, interne Pfade und Request-Metadaten gehen stattdessen in die strukturierte Logging-Pipeline, wo Entwickler*Innen sie finden können und Angreifer*Innen nicht. Der Client erhält eine generische Meldung und eine Correlation ID: genug für ein Support-Gespräch, nutzlos für Reconnaissance. Der 5xx-Pfad gibt nur ‘Internal Server Error’ zurück; 4xx-Responses zeigen nur das, was die Error Class explizit als sicher markiert. Die correlationID wird über crypto.randomUUID() erzeugt und nicht aus einem vom User gelieferten Wert abgeleitet, sodass sie nicht manipuliert werden kann, um Rückschlüsse auf den internen Zustand zu ziehen.
Praktik 3: Jeden Request validieren, nicht nur den ersten
Ein*e User*In loggt sich ein, bekommt ein Session Token, und ab da läuft jeder API Call durch, weil das System einmal geprüft und sich entschieden hat zu vertrauen. Dreißig Minuten später hat sich ihre Rolle geändert, das Token ist noch gültig, und die Requests laufen weiter mit veralteten Berechtigungen. Das ist eine der leiseren Vulnerabilities, weil sie nicht wie ein Bug aussieht. Das Auth-System funktioniert. Das Problem ist, dass Authorization als einmaliges Event behandelt wurde. Gecachte Berechtigungen, die nie aktualisiert werden, langlebige Tokens, interne Endpoints ohne Auth-Logik, weil „die rufen ja nur unsere eigenen Services auf” – in einer Microservices-Architektur potenziert sich das schnell.
- Authorization über einen einzigen Enforcement Point laufen lassen. Eine Stelle, die Access-Entscheidungen trifft und sie konsistent über alle Services anwendet. Auth-Logik über einzelne Endpoints zu verteilen ist der Weg, auf dem Checks vergessen werden.
- Tokens und Sessions kurzlebig halten. Stelle Tokens mit kurzen Gültigkeitsfenstern aus und erzwinge in sinnvollen Intervallen eine erneute Validierung. Ändern sich die Berechtigungen einer Userin mitten in der Session, sollte das im nächsten Request berücksichtigt sein.
- Interne APIs genauso behandeln wie externe. „Dieser Endpoint wird nur von unseren eigenen Services aufgerufen” ist keine Sicherheitsgrenze. Service-to-Service-Calls brauchen ebenfalls Auth Checks.
Praktik 4: Sichere Defaults setzen, nicht auf Konfiguration verlassen
Ein*e Entwickler*In generiert einen neuen Service per Scaffolding, pusht ihn ins Staging, und es funktioniert. Niemand hinterfragt die Defaults. Monate später deckt ein Security Review auf, dass CORS so eingestellt ist, dass alle Origins erlaubt sind, Session Cookies das Secure Flag nicht gesetzt haben und das Admin Panel ohne Authentication erreichbar ist. Frameworks und Cloud-Plattformen sind darauf optimiert, dass man schnell loslegen kann, und das bedeutet Auslieferung mit permissiven Einstellungen. Die Annahme ist, dass Teams die Dinge vor Production härten. In der Praxis ist die Konfiguration, die in Development funktioniert hat, die Konfiguration, die ausgeliefert wird.
- Projekt-Templates härten. Wenn dein Team Starter Repos, Helm Charts oder Terraform Module nutzt, sperre sie ab: striktes CORS, sichere Cookie Flags (z. B. Secure, HttpOnly, SameSite), TLS verpflichtend, Admin-Interfaces deaktiviert. Jedes Projekt, das diese Templates erbt, startet sicher, ohne dass sich jemand daran erinnern muss.
- Riskante Features auf Opt-in, nicht Opt-out. Debug Modes, Verbose Logging, permissive Firewall Rules: Das Aktivieren sollte eine bewusste Änderung erfordern. Wenn das Einschalten von etwas Gefährlichem so einfach ist wie das Nicht-Ausschalten, wird es jemand versehentlich ausliefern.
- Bei fehlender Konfiguration scheitern. Fehlt eine Umgebungsvariable, sollte die Anwendung sich weigern zu starten, statt stillschweigend einen permissiven Fallback anzuwenden. Ein lautes Startup Failure ist besser als ein leise laufender Service mit offenen Berechtigungen.
Ein Docker-Compose-File ist eine praktische Stelle, an der diese Lücke sichtbar wird. Beide Konfigurationen lassen denselben Service laufen, was sich ändert, ist, wie viel vom Host der Container erreichen kann.
Vorher: Was Frameworks standardmäßig generieren
services:
api:
image: my-api:latest
ports:
- "3000:3000"
environment:
- NODE_ENV=productionNachher: Gehärtete Version
services:
api:
image: my-api:1.4.2@sha256:abc123def456... # pinned digest, not :latest
ports:
- "127.0.0.1:3000:3000" # bind to loopback, expose via reverse proxy
environment:
- NODE_ENV=production
user: "10001:10001" # non-root UID/GID
read_only: true # immutable root filesystem
tmpfs:
- /tmp:size=64M,mode=1777 # writable scratch space only where needed
cap_drop:
- ALL # drop all Linux capabilities, add back only what's needed
networks:
- backend
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
networks:
backend:
driver: bridge
internal: true # no direct egress; outbound via explicit proxy---Zwei Dinge in der gehärteten Version werden Anwendungen beim ersten Start kaputtmachen: read_only: true scheitert sofort, wenn die Anwendung in einen Pfad schreibt, der nicht explizit durch ein tmpfs Mount abgedeckt ist – prüfe vor dem Aktivieren auf Log-Verzeichnisse, Temp Files und PID Files. cap_drop: ALL entfernt alle Linux Capabilities, was Anwendungen kaputtmacht, die sich an Port 80 binden oder auf CAP_NET_BIND_SERVICE angewiesen sind; füge nur die spezifische Capability wieder hinzu, die du brauchst. internal: true kappt sämtlichen ausgehenden Traffic aus dem Container – entferne es, wenn der Service legitime Calls an externe APIs macht, und leite ausgehenden Traffic stattdessen über einen expliziten Proxy.
Über diese Einschränkungen hinaus: :latest wird durch eine gepinnte Version und einen SHA256 Digest ersetzt, was verhindert, dass stille Upstream-Image-Swaps Änderungen einbringen, die du nicht reviewt hast. Das Port Binding ist auf 127.0.0.1 beschränkt, sodass externer Traffic über einen Reverse Proxy laufen muss und nicht direkt am Container ankommt. Der Container läuft als Non-Root-User – UID/GID 10001 – was begrenzt, was ein kompromittierter Prozess auf dem Host anrichten kann. Memory und CPU Limits sind explizit gesetzt, was den Blast Radius eines Resource-Exhaustion-Angriffs oder eines durchgerutschten Cryptomining-Payloads begrenzt.
Praktik 5: Heute einen Security Check in deiner Pipeline automatisieren
Du lässt vor dem Merge bereits Linter und Tests laufen. Einen Security Check hinzuzufügen folgt demselben Prinzip: Probleme automatisch abfangen, bevor sie Production erreichen. Trotzdem findet Security-Validierung in vielen Teams immer noch manuell oder gar nicht statt – bis wenige Wochen vor einem Release. Der andere Failure Mode ist Überkorrektur: drei Scanner gleichzeitig, eine Flut von False Positives, und innerhalb eines Monats klickt das Team bei allem „ignorieren”. Fang mit einem Check an. Bring den Noise-Pegel unter Kontrolle, dann erweitere.
- Dependency Scanning ist der reibungsärmste Einstieg. Tools wie Dependabot oder OWASP Dependency-Check melden bekannte Vulnerabilities mit minimaler Konfiguration. Ein CVE in einer Dependency ist ein verifizierbarer Fakt, keine heuristische Schätzung – das Signal-Rausch-Verhältnis ist besser als bei den meisten SAST Tools.
- Ergänze deine Dependency Checks um einen Secret Scanner. Tools wie gitleaks oder TruffleHog scannen Commits nach API Keys, Tokens und hardcodierten Secrets, bevor sie einen Shared Branch erreichen.
- Setze einen Severity-Schwellenwert, an den sich dein Team tatsächlich hält. Build bei Critical und High brechen, Medium als Warning anzeigen, Low unterdrücken. Justiere nach dem, worauf dein Team tatsächlich reagiert. Wenn alles blockiert, werden Entwicklerinnen und Entwickler den Check umgehen.
- Ownership für Findings zuweisen, nicht nur für das Tool. Ein Scanner ohne jemanden, der für die Triage seines Outputs verantwortlich ist, ist ein Scanner, der ignoriert wird. Lege im Voraus fest, wer reviewt, wer über Fix oder Suppress entscheidet und wie schnell.
Hier ein minimaler GitHub Actions Workflow mit Grype – ein Scanner, ein Severity-Schwellenwert und die Ergebnisse an einem Ort, an dem tatsächlich jemand für sie verantwortlich ist.
GitHub Actions: Grype
name: security-scan
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # weekly, Mondays 06:00 UTC - catches CVEs disclosed after last commit
workflow_dispatch: # allow manual triggering
jobs:
dependency-scan:
name: Dependency vulnerability scan
runs-on: ubuntu-latest
timeout-minutes: 10 # prevent hung jobs from blocking the queue
permissions:
contents: read
security-events: write # required to upload SARIF
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Scan repository with Grype
id: scan
uses: anchore/scan-action@v4 # pin to specific version; verify latest before publish
with:
path: "."
severity-cutoff: high # fail build on HIGH or CRITICAL findings
fail-build: true
output-format: sarif
only-fixed: true # skip vulns with no available fix - noise reduction
- name: Upload findings to GitHub Security tab
if: always() # upload even when the previous step fails the build
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
category: grype # disambiguates findings if you add more scanners laterDer Workflow läuft bei jedem Pull Request, bei jedem Push auf main und nach einem wöchentlichen Schedule – der Schedule ist wichtig, weil CVEs kontinuierlich offengelegt werden, nicht nur dann, wenn sich Code ändert. severity-cutoff: high bedeutet, dass HIGH– und CRITICAL-Findings den Build brechen; alles darunter erscheint informativ, ohne die Pipeline zu blockieren. only-fixed: true ist erwähnenswert: Es filtert Vulnerabilities ohne verfügbaren Patch heraus, was eine bedeutende Quelle von Alert Fatigue eliminiert. Findings werden als SARIF exportiert und in den GitHub Security Tab hochgeladen, was ihnen eine UI, einen Triage-Workflow und einen zuweisbaren Owner gibt – statt in CI Logs zu verschwinden, die nach einem erfolgreichen Build niemand mehr ansieht.
Vom ersten Schritt zur Secure-by-Design-Gewohnheit
Fünf Praktiken, keine davon erfordert eine Reorganisation. Service Accounts eingrenzen, fail-secure scheitern, jeden Request validieren, Defaults härten, einen automatisierten Check hinzufügen.
Wenn du eine umgesetzt hast und sie funktioniert hat, nimm dir die nächste vor. So entsteht Security-Praxis: durch inkrementelle Änderungen, die sich über die Zeit aufaddieren. Der schwierige Teil ist keine einzelne Praktik. Es ist die Gewohnheit, Security als Teil der Arbeit zu behandeln und nicht als separate Aufgabe.




