Zum Inhalt springen
·30 Min. Lesezeit

API-Sicherheit Best Practices: Die Checkliste, die ich bei jedem Projekt durchgehe

Authentifizierung, Autorisierung, Input-Validierung, Rate Limiting, CORS, Secrets Management und die OWASP API Top 10. Was ich vor jedem Produktions-Deployment prüfe.

Teilen:X / TwitterLinkedIn

Ich habe APIs ausgeliefert, die weit offen waren. Nicht böswillig, nicht faul — ich wusste einfach nicht, was ich nicht wusste. Ein Endpoint, der jedes Feld im User-Objekt zurückgab, einschließlich gehashter Passwörter. Ein Rate Limiter, der nur IP-Adressen prüfte, was bedeutete, dass jeder hinter einem Proxy die API hämmern konnte. Eine JWT-Implementierung, bei der ich vergessen hatte, den iss-Claim zu verifizieren, sodass Tokens von einem völlig anderen Dienst problemlos funktionierten.

Jeder dieser Fehler schaffte es in die Produktion. Jeder wurde entdeckt — manche von mir, manche von Benutzern, einer von einem Security-Researcher, der freundlich genug war, mir eine E-Mail zu schicken, statt es auf Twitter zu posten.

Dieser Beitrag ist die Checkliste, die ich aus diesen Fehlern gebaut habe. Ich gehe sie vor jedem Produktions-Deployment durch. Nicht weil ich paranoid bin, sondern weil ich gelernt habe, dass Security-Bugs die sind, die am meisten wehtun. Ein kaputter Button nervt Benutzer. Ein kaputter Auth-Flow leakt ihre Daten.

Authentifizierung vs Autorisierung#

Diese beiden Wörter werden in Meetings, in Docs und sogar in Code-Kommentaren austauschbar verwendet. Sie sind nicht dasselbe.

Authentifizierung beantwortet: „Wer bist du?" Es ist der Login-Schritt. Benutzername und Passwort, OAuth-Flow, Magic Link — was auch immer deine Identität beweist.

Autorisierung beantwortet: „Was darfst du tun?" Es ist der Berechtigungs-Schritt. Darf dieser Benutzer diese Ressource löschen? Darf er auf diesen Admin-Endpoint zugreifen? Darf er die Daten eines anderen Benutzers lesen?

Der häufigste Security-Bug, den ich in Produktions-APIs gesehen habe, ist kein kaputter Login-Flow. Es ist eine fehlende Autorisierungsprüfung. Der Benutzer ist authentifiziert — er hat ein gültiges Token — aber die API prüft nie, ob er die Aktion ausführen darf, die er anfordert.

JWT: Anatomie und die Fehler, die zählen#

JWTs sind überall. Sie werden auch überall missverstanden. Ein JWT hat drei Teile, getrennt durch Punkte:

header.payload.signature

Der Header sagt, welcher Algorithmus verwendet wurde. Die Payload enthält Claims (Benutzer-ID, Rollen, Ablaufzeit). Die Signatur beweist, dass niemand an den ersten beiden Teilen manipuliert hat.

Hier ist eine ordentliche JWT-Verifikation in Node.js:

typescript
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
 
interface TokenPayload {
  sub: string;
  role: "user" | "admin";
  iss: string;
  aud: string;
  exp: number;
  iat: number;
  jti: string;
}
 
function verifyToken(token: string): TokenPayload {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ["HS256"], // Niemals "none" erlauben
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 Sekunden Toleranz für Uhrenabweichung
    }) as TokenPayload;
 
    return payload;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, "Token abgelaufen");
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new ApiError(401, "Ungültiges Token");
    }
    throw new ApiError(401, "Authentifizierung fehlgeschlagen");
  }
}

Ein paar Dinge, die auffallen:

  1. algorithms: ["HS256"] — Das ist kritisch. Wenn du den Algorithmus nicht angibst, kann ein Angreifer ein Token mit "alg": "none" im Header senden und die Verifikation komplett überspringen. Das ist die alg: none-Attacke, und sie hat echte Produktionssysteme betroffen.

  2. issuer und audience — Ohne diese funktioniert ein Token, das für Service A erstellt wurde, auch auf Service B. Wenn du mehrere Dienste betreibst, die dasselbe Secret teilen (was du nicht solltest, aber Leute tun es), ist das die Methode für dienstübergreifenden Token-Missbrauch.

  3. Spezifische Fehlerbehandlung — Gib nicht „ungültiges Token" für jeden Fehler zurück. Die Unterscheidung zwischen abgelaufen und ungültig hilft dem Client zu wissen, ob er refreshen oder sich neu authentifizieren soll.

Refresh-Token-Rotation#

Access Tokens sollten kurzlebig sein — 15 Minuten ist Standard. Aber du willst nicht, dass Benutzer alle 15 Minuten ihr Passwort erneut eingeben. Dafür sind Refresh Tokens da.

Das Muster, das in der Produktion tatsächlich funktioniert:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Token-Familie für Rotations-Erkennung
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token nicht gefunden — entweder abgelaufen oder bereits verwendet.
    // Wenn bereits verwendet, ist das ein potenzieller Replay-Angriff.
    // Gesamte Token-Familie ungültig machen.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Ungültiges Refresh Token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Altes Token sofort löschen — einmalige Verwendung
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Neue Tokens generieren
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Neues Refresh Token mit derselben Familie speichern
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 Tage
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Nach allen Tokens in dieser Familie suchen und löschen.
  // Das ist die nukleare Option — wenn jemand ein Refresh Token wiederverwendet,
  // killen wir jedes Token in der Familie und erzwingen Neu-Authentifizierung.
  const keys = await redis.keys(`refresh:*`);
  for (const key of keys) {
    const data = await redis.get(key);
    if (data) {
      const parsed = JSON.parse(data) as RefreshTokenData;
      if (parsed.family === family) {
        await redis.del(key);
      }
    }
  }
}

Das Token-Familie-Konzept ist das, was das Ganze sicher macht. Jedes Refresh Token gehört zu einer Familie (erstellt beim Login). Bei der Rotation erbt das neue Token die Familie. Wenn ein Angreifer ein altes Refresh Token wiederverwendet, erkennst du die Wiederverwendung und killst die gesamte Familie. Der legitime Benutzer wird ausgeloggt, aber der Angreifer kommt nicht rein.

Diese Debatte läuft seit Jahren, und die Antwort ist klar: httpOnly-Cookies für Refresh Tokens, immer.

localStorage ist für jedes JavaScript zugänglich, das auf deiner Seite läuft. Wenn du eine einzige XSS-Schwachstelle hast — und im Großen und Ganzen wirst du irgendwann eine haben — kann der Angreifer das Token lesen und exfiltrieren. Game over.

httpOnly-Cookies sind für JavaScript nicht zugänglich. Punkt. Eine XSS-Schwachstelle kann immer noch Anfragen im Namen des Benutzers machen (weil Cookies automatisch gesendet werden), aber der Angreifer kann das Token selbst nicht stehlen. Das ist ein bedeutsamer Unterschied.

typescript
// Ein sicheres Refresh-Token-Cookie setzen
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Nicht über JavaScript zugänglich
    secure: true,       // Nur HTTPS
    sameSite: "strict", // Keine Cross-Site-Anfragen
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 Tage
    path: "/api/auth",  // Nur an Auth-Endpoints gesendet
  });
}

Das path: "/api/auth" ist ein Detail, das die meisten übersehen. Standardmäßig werden Cookies an jeden Endpoint auf deiner Domain gesendet. Dein Refresh Token muss nicht an /api/users oder /api/products gehen. Schränke den Pfad ein, reduziere die Angriffsfläche.

Für Access Tokens halte ich sie im Speicher (eine JavaScript-Variable). Nicht localStorage, nicht sessionStorage, nicht ein Cookie. Im Speicher. Sie sind kurzlebig (15 Minuten), und wenn die Seite neu geladen wird, ruft der Client leise den Refresh-Endpoint auf, um ein neues zu bekommen. Ja, das bedeutet eine zusätzliche Anfrage beim Seitenaufruf. Es lohnt sich.

Input-Validierung: Vertraue niemals dem Client#

Der Client ist nicht dein Freund. Der Client ist ein Fremder, der in dein Haus kam und sagte „Ich darf hier sein." Du prüfst trotzdem seinen Ausweis.

Jedes Datenstück, das von außerhalb deines Servers kommt — Request Body, Query-Parameter, URL-Parameter, Header — ist nicht vertrauenswürdige Eingabe. Es spielt keine Rolle, dass dein React-Formular Validierung hat. Jemand wird es mit curl umgehen.

Zod für typsichere Validierung#

Zod ist das Beste, was der Node.js-Input-Validierung passiert ist. Es gibt dir Runtime-Validierung mit TypeScript-Typen kostenlos dazu:

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Ungültiges E-Mail-Format")
    .max(254, "E-Mail zu lang")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "Passwort muss mindestens 12 Zeichen lang sein")
    .max(128, "Passwort zu lang") // bcrypt-DoS verhindern
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten"
    ),
 
  name: z
    .string()
    .min(1, "Name ist erforderlich")
    .max(100, "Name zu lang")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "Name enthält ungültige Zeichen"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // Hinweis: "admin" ist hier absichtlich keine Option.
  // Admin-Rollenzuweisung läuft über einen separaten, privilegierten Endpoint.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Verwendung in einem Express-Handler
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "Validierung fehlgeschlagen",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data ist vollständig typisiert als CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Ein paar sicherheitsrelevante Details:

  • max(128) beim Passwort — bcrypt hat ein 72-Byte-Eingabelimit, und manche Implementierungen schneiden stillschweigend ab. Aber noch wichtiger: Wenn du ein 10-MB-Passwort erlaubst, wird bcrypt erhebliche Zeit zum Hashen brauchen. Das ist ein DoS-Vektor.
  • max(254) bei der E-Mail — RFC 5321 begrenzt E-Mail-Adressen auf 254 Zeichen. Alles darüber hinaus ist keine gültige E-Mail.
  • Enum für Rolle, ohne Admin — Mass Assignment ist eine der ältesten API-Schwachstellen. Wenn du die Rolle aus dem Request Body akzeptierst, ohne sie zu validieren, wird jemand "role": "admin" senden und auf das Beste hoffen.

SQL Injection ist nicht gelöst#

„Verwende einfach ein ORM" schützt dich nicht, wenn du Raw Queries für Performance schreibst. Und jeder schreibt irgendwann Raw Queries für Performance.

typescript
// VERWUNDBAR — String-Verkettung
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// SICHER — parametrisierte Query
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Mit Prisma bist du größtenteils sicher — aber $queryRaw kann dich immer noch erwischen:

typescript
// VERWUNDBAR — Template-Literal in $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// SICHER — Prisma.sql für Parametrisierung verwenden
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL Injection#

MongoDB verwendet kein SQL, aber es ist nicht immun gegen Injection. Wenn du nicht bereinigte Benutzereingaben als Query-Objekt übergibst, geht etwas schief:

typescript
// VERWUNDBAR — wenn req.body.username { "$gt": "" } ist
// gibt das den ersten Benutzer in der Collection zurück
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// SICHER — explizit zu String zwingen
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// BESSER — zuerst mit Zod validieren
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

Der Fix ist einfach: Validiere Input-Typen, bevor sie deinen Datenbanktreiber erreichen. Wenn username ein String sein soll, stelle sicher, dass es ein String ist.

Path Traversal#

Wenn deine API Dateien ausliefert oder von einem Pfad liest, der Benutzereingaben enthält, wird Path Traversal deine Woche ruinieren:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normalisieren und zu einem absoluten Pfad auflösen
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Kritisch: verifizieren, dass der aufgelöste Pfad noch innerhalb des erlaubten Verzeichnisses ist
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Zugriff verweigert");
  }
 
  // Verifizieren, dass die Datei tatsächlich existiert
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Ohne diese Prüfung:
// GET /api/files?name=../../../etc/passwd
// löst sich zu /etc/passwd auf

Das path.resolve + startsWith-Muster ist der korrekte Ansatz. Versuche nicht, ../ manuell zu entfernen — es gibt zu viele Encoding-Tricks (..%2F, ..%252F, ....//), die dein Regex umgehen werden.

Rate Limiting#

Ohne Rate Limiting ist deine API ein All-you-can-eat-Buffet für Bots. Brute-Force-Angriffe, Credential Stuffing, Ressourcenerschöpfung — Rate Limiting ist die erste Verteidigung gegen all das.

Token Bucket vs Sliding Window#

Token Bucket: Du hast einen Eimer, der N Tokens enthält. Jede Anfrage kostet ein Token. Tokens füllen sich mit einer festen Rate nach. Wenn der Eimer leer ist, wird die Anfrage abgelehnt. Das erlaubt Bursts — wenn der Eimer voll ist, kannst du N Anfragen sofort machen.

Sliding Window: Zähle Anfragen innerhalb eines beweglichen Zeitfensters. Vorhersehbarer, schwerer zu durchbrechen.

Ich verwende Sliding Window für die meisten Dinge, weil das Verhalten einfacher zu verstehen und dem Team zu erklären ist:

typescript
import { Redis } from "ioredis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function slidingWindowRateLimit(
  redis: Redis,
  key: string,
  limit: number,
  windowMs: number
): Promise<RateLimitResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const multi = redis.multi();
 
  // Einträge außerhalb des Fensters entfernen
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Einträge im Fenster zählen
  multi.zcard(key);
 
  // Aktuelle Anfrage hinzufügen (wir entfernen sie, wenn über dem Limit)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Ablaufzeit auf dem Schlüssel setzen
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Redis-Transaktion fehlgeschlagen");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // Über dem Limit — den gerade hinzugefügten Eintrag entfernen
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

Mehrschichtige Rate Limits#

Ein einziges globales Rate Limit reicht nicht. Verschiedene Endpoints haben verschiedene Risikoprofile:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Auth-Endpoints — enge Limits, Brute-Force-Ziel
  "POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
  "POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
  "POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
 
  // Daten-Lesezugriffe — großzügiger
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Daten-Schreibzugriffe — moderat
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Globaler Fallback
  "*": { window: 60 * 1000, max: 60 },
};
 
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
  const identifier = req.user?.id ?? getClientIp(req);
  const endpoint = `${req.method}:${req.path}`;
  return `ratelimit:${identifier}:${endpoint}`;
}

Beachte: Authentifizierte Benutzer werden nach Benutzer-ID limitiert, nicht nach IP. Das ist wichtig, weil viele legitime Benutzer sich IPs teilen (Firmennetzwerke, VPNs, Mobilfunkanbieter). Wenn du nur nach IP limitierst, sperrst du ganze Büros aus.

Rate-Limit-Header#

Teile dem Client immer mit, was los ist:

typescript
function setRateLimitHeaders(
  res: Response,
  result: RateLimitResult,
  limit: number
): void {
  res.set({
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": result.remaining.toString(),
    "X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
    "Retry-After": result.allowed
      ? undefined
      : Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
  });
 
  if (!result.allowed) {
    res.status(429).json({
      error: "Zu viele Anfragen",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

CORS-Konfiguration#

CORS ist wahrscheinlich der am meisten missverstandene Sicherheitsmechanismus in der Webentwicklung. Die Hälfte der Stack-Overflow-Antworten über CORS lauten „setz einfach Access-Control-Allow-Origin: * und es funktioniert." Das ist technisch korrekt. So öffnest du auch deine API für jede bösartige Seite im Internet.

Was CORS tatsächlich tut (und was nicht)#

CORS ist ein Browser-Mechanismus. Er teilt dem Browser mit, ob JavaScript von Origin A die Antwort von Origin B lesen darf. Das ist alles.

Was CORS nicht tut:

  • Es schützt deine API nicht vor curl, Postman oder Server-zu-Server-Anfragen
  • Es authentifiziert keine Anfragen
  • Es verschlüsselt nichts
  • Es verhindert CSRF nicht allein (obwohl es hilft, wenn es mit anderen Mechanismen kombiniert wird)

Was CORS tut:

  • Es verhindert, dass boesartige-website.com Fetch-Anfragen an deine-api.com stellt und die Antwort im Browser des Benutzers liest
  • Es verhindert, dass das JavaScript des Angreifers Daten über die authentifizierte Sitzung des Opfers exfiltriert

Die Wildcard-Falle#

typescript
// GEFÄHRLICH — erlaubt jeder Website, deine API-Antworten zu lesen
app.use(cors({ origin: "*" }));
 
// AUCH GEFÄHRLICH — das ist ein häufiger "dynamischer" Ansatz, der nur * mit extra Schritten ist
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Erlaubt alles
    },
  })
);

Das Problem mit * ist, dass es deine API-Antworten für jedes JavaScript auf jeder Seite lesbar macht. Wenn deine API Benutzerdaten zurückgibt und der Benutzer über Cookies authentifiziert ist, kann jede Website, die der Benutzer besucht, diese Daten lesen.

Noch schlimmer: Access-Control-Allow-Origin: * kann nicht mit credentials: true kombiniert werden. Wenn du also Cookies brauchst (für Auth), kannst du die Wildcard buchstäblich nicht verwenden. Aber ich habe gesehen, wie Leute das umgehen, indem sie den Origin-Header zurückspiegeln — was äquivalent zu * mit Credentials ist, das Schlimmste aus beiden Welten.

Die korrekte Konfiguration#

typescript
import cors from "cors";
 
const ALLOWED_ORIGINS = new Set([
  "https://yourapp.com",
  "https://www.yourapp.com",
  "https://admin.yourapp.com",
]);
 
if (process.env.NODE_ENV === "development") {
  ALLOWED_ORIGINS.add("http://localhost:3000");
  ALLOWED_ORIGINS.add("http://localhost:5173");
}
 
app.use(
  cors({
    origin: (origin, callback) => {
      // Anfragen ohne Origin erlauben (Mobile Apps, curl, Server-zu-Server)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origin ${origin} nicht von CORS erlaubt`));
    },
    credentials: true, // Cookies erlauben
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Preflight für 24 Stunden cachen
  })
);

Wichtige Entscheidungen:

  • Explizites Origin-Set, kein Regex. Regex sind tückisch — yourapp.com könnte evilyourapp.com matchen, wenn dein Regex nicht korrekt verankert ist.
  • credentials: true weil wir httpOnly-Cookies für Refresh Tokens verwenden.
  • maxAge: 86400 — Preflight-Anfragen (OPTIONS) fügen Latenz hinzu. Dem Browser zu sagen, das CORS-Ergebnis für 24 Stunden zu cachen, reduziert unnötige Roundtrips.
  • exposedHeaders — Standardmäßig macht der Browser nur eine Handvoll „einfacher" Antwort-Header für JavaScript zugänglich. Wenn du willst, dass der Client deine Rate-Limit-Header lesen kann, musst du sie explizit freigeben.

Preflight-Anfragen#

Wenn eine Anfrage nicht „einfach" ist (sie verwendet einen nicht-standardmäßigen Header, eine nicht-standardmäßige Methode oder einen nicht-standardmäßigen Content-Type), sendet der Browser zuerst eine OPTIONS-Anfrage, um um Erlaubnis zu fragen. Das ist der Preflight.

Wenn deine CORS-Konfiguration OPTIONS nicht behandelt, werden Preflight-Anfragen fehlschlagen, und die eigentliche Anfrage wird nie gesendet. Die meisten CORS-Bibliotheken handhaben das automatisch, aber wenn du ein Framework verwendest, das es nicht tut, musst du es behandeln:

typescript
// Manuelle Preflight-Behandlung (die meisten Frameworks machen das für dich)
app.options("*", (req, res) => {
  res.set({
    "Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
  });
  res.status(204).end();
});

Security Headers#

Security Headers sind die günstigste Sicherheitsverbesserung, die du machen kannst. Es sind Antwort-Header, die dem Browser sagen, Sicherheits-Features zu aktivieren. Die meisten sind eine einzelne Zeile Konfiguration, und sie schützen gegen ganze Klassen von Angriffen.

Die Header, die zählen#

typescript
import helmet from "helmet";
 
// Eine Zeile. Das ist der schnellste Security-Gewinn in jeder Express-App.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Nötig für viele CSS-in-JS-Lösungen
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 Jahr
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Was jeder Header tut:

Content-Security-Policy (CSP) — Der mächtigste Security-Header. Er sagt dem Browser genau, welche Quellen für Scripts, Styles, Bilder, Fonts usw. erlaubt sind. Wenn ein Angreifer ein <script>-Tag injiziert, das von evil.com lädt, blockiert CSP es. Das ist die wirksamste einzelne Verteidigung gegen XSS.

Strict-Transport-Security (HSTS) — Sagt dem Browser, immer HTTPS zu verwenden, selbst wenn der Benutzer http:// eingibt. Die preload-Direktive ermöglicht es, deine Domain in die im Browser eingebaute HSTS-Liste einzutragen, sodass sogar die allererste Anfrage auf HTTPS erzwungen wird.

X-Frame-Options — Verhindert, dass deine Seite in einem iframe eingebettet wird. Das stoppt Clickjacking-Angriffe, bei denen ein Angreifer deine Seite mit unsichtbaren Elementen überlagert. Helmet setzt dies standardmäßig auf SAMEORIGIN. Der moderne Ersatz ist frame-ancestors in CSP.

X-Content-Type-Options: nosniff — Verhindert, dass der Browser den MIME-Typ einer Antwort errät (snifft). Ohne das könnte der Browser, wenn du eine Datei mit dem falschen Content-Type auslieferst, sie als JavaScript ausführen.

Referrer-Policy — Kontrolliert, wie viel URL-Information im Referer-Header gesendet wird. strict-origin-when-cross-origin sendet die vollständige URL für Same-Origin-Anfragen, aber nur den Origin für Cross-Origin-Anfragen. Das verhindert das Leaken sensibler URL-Parameter an Dritte.

Deine Header testen#

Nach dem Deployment prüfe deinen Score auf securityheaders.com. Ziele auf eine A+-Bewertung. Es dauert etwa fünf Minuten Konfiguration, um dorthin zu kommen.

Du kannst Header auch programmatisch verifizieren:

typescript
import { describe, it, expect } from "vitest";
 
describe("Security Headers", () => {
  it("sollte alle erforderlichen Security Headers enthalten", async () => {
    const response = await fetch("https://api.yourapp.com/health");
 
    expect(response.headers.get("strict-transport-security")).toBeTruthy();
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
    expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
    expect(response.headers.get("content-security-policy")).toBeTruthy();
    expect(response.headers.get("referrer-policy")).toBeTruthy();
    expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet entfernt das
  });
});

Die x-powered-by-Prüfung ist subtil, aber wichtig. Express setzt standardmäßig X-Powered-By: Express, was Angreifern genau sagt, welches Framework du verwendest. Helmet entfernt es.

Secrets Management#

Das sollte offensichtlich sein, aber ich sehe es immer noch in Pull Requests: API-Keys, Datenbankpasswörter und JWT-Secrets, die in Quelldateien hartcodiert sind. Oder in .env-Dateien committed, die nicht in .gitignore waren. Einmal in der Git-Historie, ist es für immer dort, selbst wenn du die Datei im nächsten Commit löschst.

Die Regeln#

  1. Committe niemals Secrets zu Git. Nicht im Code, nicht in .env, nicht in Konfigurationsdateien, nicht in Docker-Compose-Dateien, nicht in „nur zum Testen"-Kommentaren.

  2. Verwende .env.example als Vorlage. Es dokumentiert, welche Umgebungsvariablen benötigt werden, ohne tatsächliche Werte zu enthalten:

bash
# .env.example — das committen
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
 
# .env — das NIEMALS committen
# In .gitignore gelistet
  1. Validiere Umgebungsvariablen beim Start. Warte nicht, bis eine Anfrage einen Endpoint trifft, der die Datenbank-URL braucht. Fail fast:
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT Secret muss mindestens 32 Zeichen lang sein"),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
 
export type Env = z.infer<typeof envSchema>;
 
function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error("Ungültige Umgebungsvariablen:");
    console.error(result.error.format());
    process.exit(1); // Nicht mit fehlerhafter Konfiguration starten
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. Verwende einen Secret Manager in der Produktion. Umgebungsvariablen funktionieren für einfache Setups, haben aber Einschränkungen: Sie sind in Prozesslisten sichtbar, sie bleiben im Speicher und können durch Fehlerlogs leaken.

Für Produktionssysteme verwende einen richtigen Secret Manager:

  • AWS Secrets Manager oder SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (wenn du etwas willst, das über alle Clouds funktioniert)

Das Muster ist dasselbe, unabhängig davon, welchen du verwendest: Die Anwendung holt Secrets beim Start vom Secret Manager, nicht aus Umgebungsvariablen.

  1. Rotiere Secrets regelmäßig. Wenn du dasselbe JWT-Secret seit zwei Jahren verwendest, ist es Zeit zu rotieren. Implementiere Key-Rotation: Unterstütze mehrere gültige Signing Keys gleichzeitig, signiere neue Tokens mit dem neuen Key, verifiziere mit sowohl altem als auch neuem, und pensioniere den alten Key, nachdem alle bestehenden Tokens abgelaufen sind.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Nur der aktive Key signiert neue Tokens
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Gibt alle gültigen Keys zurück
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Nächsten Key versuchen
    }
  }
 
  throw new ApiError(401, "Ungültiges Token");
}
 
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  const activeKey = getActiveSigningKey();
  return jwt.sign(payload, activeKey.secret, {
    algorithm: "HS256",
    expiresIn: "15m",
    keyid: activeKey.id, // Key-ID im Header einschließen
  });
}

OWASP API Security Top 10#

Die OWASP API Security Top 10 ist die Industriestandard-Liste der API-Schwachstellen. Sie wird regelmäßig aktualisiert, und jeder Punkt auf der Liste ist etwas, das ich in echten Codebasen gesehen habe. Lass mich jeden einzelnen durchgehen.

API1: Broken Object Level Authorization (BOLA)#

Die häufigste API-Schwachstelle. Der Benutzer ist authentifiziert, aber die API prüft nicht, ob er Zugriff auf das spezifische Objekt hat, das er anfordert.

typescript
// VERWUNDBAR — jeder authentifizierte Benutzer kann auf die Daten jedes Benutzers zugreifen
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// GEFIXT — verifizieren, dass der Benutzer auf seine eigenen Daten zugreift (oder Admin ist)
app.get("/api/users/:id", authenticate, async (req, res) => {
  if (req.user.id !== req.params.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Zugriff verweigert" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

Die verwundbare Version ist überall. Sie besteht jede Auth-Prüfung — der Benutzer hat ein gültiges Token — aber sie verifiziert nicht, ob er autorisiert ist, auf diese spezifische Ressource zuzugreifen. Ändere die ID in der URL, und du bekommst die Daten von jemand anderem.

API2: Broken Authentication#

Schwache Login-Mechanismen, fehlende MFA, Tokens, die nie ablaufen, Passwörter im Klartext gespeichert. Das deckt die Authentifizierungsschicht selbst ab.

Der Fix ist alles, was wir im Authentifizierungsabschnitt besprochen haben: Starke Passwortanforderungen, bcrypt mit ausreichenden Runden, kurzlebige Access Tokens, Refresh-Token-Rotation, Account-Sperrung nach fehlgeschlagenen Versuchen.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 Minuten
 
async function handleLogin(email: string, password: string): Promise<AuthResult> {
  const lockoutKey = `lockout:${email}`;
  const attempts = await redis.get(lockoutKey);
 
  if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
    const ttl = await redis.pttl(lockoutKey);
    throw new ApiError(
      429,
      `Account gesperrt. Versuche es in ${Math.ceil(ttl / 60000)} Minuten erneut.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // Fehlgeschlagene Versuche inkrementieren
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Gleiche Fehlermeldung für beide Fälle — nicht verraten, ob die E-Mail existiert
    throw new ApiError(401, "Ungültige E-Mail oder ungültiges Passwort");
  }
 
  // Fehlgeschlagene Versuche bei erfolgreichem Login zurücksetzen
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

Der Kommentar über „gleiche Fehlermeldung" ist wichtig. Wenn deine API „Benutzer nicht gefunden" für ungültige E-Mails und „falsches Passwort" für gültige E-Mails mit falschen Passwörtern zurückgibt, verrätst du einem Angreifer, welche E-Mails in deinem System existieren.

API3: Broken Object Property Level Authorization#

Mehr Daten zurückgeben als nötig, oder Benutzern erlauben, Eigenschaften zu ändern, die sie nicht ändern sollten.

typescript
// VERWUNDBAR — gibt das gesamte User-Objekt zurück, einschließlich interner Felder
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // Antwort enthält: passwordHash, internalNotes, billingId, ...
});
 
// GEFIXT — explizite Allowlist der zurückgegebenen Felder
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    createdAt: user.createdAt,
  });
});

Gib niemals ganze Datenbankobjekte zurück. Wähle immer die Felder aus, die du freigeben willst. Das gilt auch für Schreibzugriffe — verteile nicht den gesamten Request Body in deine Update-Query:

typescript
// VERWUNDBAR — Mass Assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // Angreifer sendet: { "role": "admin", "verified": true }
});
 
// GEFIXT — erlaubte Felder auswählen
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  avatar: z.string().url().optional(),
});
 
app.put("/api/users/:id", authenticate, async (req, res) => {
  const data = UpdateUserSchema.parse(req.body);
  await db.users.update(req.params.id, data);
});

API4: Unrestricted Resource Consumption#

Deine API ist eine Ressource. CPU, Speicher, Bandbreite, Datenbankverbindungen — sie sind alle endlich. Ohne Limits kann ein einzelner Client sie alle erschöpfen.

Das geht über Rate Limiting hinaus. Es umfasst:

typescript
// Request-Body-Größe begrenzen
app.use(express.json({ limit: "1mb" }));
 
// Query-Komplexität begrenzen
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
 
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_PAGE_SIZE)
    .default(DEFAULT_PAGE_SIZE),
});
 
// Datei-Upload-Größe begrenzen
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Ungültiger Dateityp"));
    }
  },
});
 
// Lang laufende Anfragen timeoutten
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Anfrage-Timeout" });
  });
  next();
});

API5: Broken Function Level Authorization#

Anders als BOLA. Hier geht es um den Zugriff auf Funktionen (Endpoints), auf die du keinen Zugriff haben solltest, nicht um Objekte. Das klassische Beispiel: Ein regulärer Benutzer entdeckt Admin-Endpoints.

typescript
// Middleware, die rollenbasierte Zugriffskontrolle prüft
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Nicht authentifiziert" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // Den Versuch loggen — das könnte ein Angriff sein
      logger.warn("Unautorisierter Zugriffsversuch", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "Unzureichende Berechtigungen" });
    }
 
    next();
  };
}
 
// Auf Routen anwenden
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);

Verlasse dich nicht darauf, Endpoints zu verstecken. „Security durch Obskurität" ist keine Sicherheit. Selbst wenn die Admin-Panel-URL nirgends verlinkt ist, wird jemand /api/admin/users durch Fuzzing finden.

API6: Unrestricted Access to Sensitive Business Flows#

Automatisierter Missbrauch legitimer Geschäftsfunktionalität. Denke an: Bots, die limitierte Lagerartikel kaufen, automatische Accounterstellung für Spam, Scraping von Produktpreisen.

Die Gegenmaßnahmen sind kontextspezifisch: CAPTCHAs, Device Fingerprinting, Verhaltensanalyse, Step-up-Authentifizierung für sensible Operationen. Es gibt kein Einheits-Code-Snippet dafür.

API7: Server Side Request Forgery (SSRF)#

Wenn deine API URLs abruft, die vom Benutzer bereitgestellt werden (Webhooks, Profilbild-URLs, Link-Vorschauen), kann ein Angreifer deinen Server dazu bringen, interne Ressourcen anzufordern:

typescript
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
 
async function safeFetch(userProvidedUrl: string): Promise<Response> {
  let parsed: URL;
 
  try {
    parsed = new URL(userProvidedUrl);
  } catch {
    throw new ApiError(400, "Ungültige URL");
  }
 
  // Nur HTTP(S) erlauben
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Nur HTTP(S)-URLs sind erlaubt");
  }
 
  // Hostnamen auflösen und prüfen, ob es eine private IP ist
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Interne Adressen sind nicht erlaubt");
    }
  }
 
  // Jetzt mit Timeout und Größenlimit abrufen
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Keine Redirects folgen (sie könnten zu internen IPs umleiten)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Wichtige Details: Löse das DNS zuerst auf und prüfe die IP bevor du die Anfrage machst. Blockiere Redirects — ein Angreifer kann eine URL hosten, die zu http://169.254.169.254/ (AWS-Metadaten-Endpoint) umleitet, um deine URL-Level-Prüfung zu umgehen.

API8: Security Misconfiguration#

Standard-Credentials unverändert gelassen, unnötige HTTP-Methoden aktiviert, ausführliche Fehlermeldungen in der Produktion, Directory Listing aktiviert, CORS falsch konfiguriert. Das ist die „du hast vergessen, die Tür abzuschließen"-Kategorie.

typescript
// Keine Stack Traces in der Produktion leaken
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("Unbehandelter Fehler", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // Generische Fehlermeldung — keine internen Details verraten
    res.status(500).json({
      error: "Interner Serverfehler",
      requestId: req.id, // Eine Request-ID zum Debuggen einschließen
    });
  } else {
    // In der Entwicklung den vollständigen Fehler anzeigen
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Unnötige HTTP-Methoden deaktivieren
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "Methode nicht erlaubt" });
  }
  next();
});

API9: Improper Inventory Management#

Du hast v2 der API deployed, aber vergessen, v1 herunterzufahren. Oder es gibt einen /debug/-Endpoint, der während der Entwicklung nützlich war und immer noch in der Produktion läuft. Oder ein Staging-Server, der öffentlich zugänglich ist mit Produktionsdaten.

Das ist kein Code-Fix — es ist eine Ops-Disziplin. Führe eine Liste aller API-Endpoints, aller deployten Versionen und aller Umgebungen. Verwende automatisiertes Scanning, um exponierte Dienste zu finden. Beende, was du nicht brauchst.

API10: Unsafe Consumption of APIs#

Deine API konsumiert Third-Party-APIs. Validierst du deren Antworten? Was passiert, wenn eine Webhook-Payload von Stripe tatsächlich von einem Angreifer stammt?

typescript
import crypto from "crypto";
 
// Stripe-Webhook-Signaturen verifizieren
function verifyStripeWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
  const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
 
  if (!timestamp || !expectedSig) return false;
 
  // Alte Timestamps ablehnen (Replay-Angriffe verhindern)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // 5 Minuten Toleranz
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Verifiziere immer Signaturen bei Webhooks. Validiere immer die Struktur von Third-Party-API-Antworten. Setze immer Timeouts auf ausgehende Anfragen. Vertraue niemals Daten, nur weil sie von „einem vertrauenswürdigen Partner" kamen.

Audit Logging#

Wenn etwas schiefgeht — und das wird es — sind Audit-Logs die Methode, um herauszufinden, was passiert ist. Aber Logging ist ein zweischneidiges Schwert. Logge zu wenig und du bist blind. Logge zu viel und du schaffst eine Datenschutz-Haftung.

Was loggen#

typescript
interface AuditLogEntry {
  timestamp: string;
  action: string;           // "user.login", "post.delete", "admin.role_change"
  actor: {
    id: string;
    ip: string;
    userAgent: string;
  };
  target: {
    type: string;           // "user", "post", "setting"
    id: string;
  };
  result: "success" | "failure";
  metadata: Record<string, unknown>; // Zusätzlicher Kontext
  requestId: string;        // Zum Korrelieren mit Anwendungslogs
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // In einen separaten, nur-anhängenden Datenspeicher schreiben
  // Das sollte NICHT dieselbe Datenbank sein, die deine Anwendung verwendet
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // Für kritische Aktionen auch in ein unveränderliches externes Log schreiben
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Diese Ereignisse loggen:

  • Authentifizierung: Logins, Logouts, fehlgeschlagene Versuche, Token-Refreshes
  • Autorisierung: Zugriff-verweigert-Ereignisse (das sind oft Angriffsindikatoren)
  • Datenänderungen: Erstellungen, Aktualisierungen, Löschungen — wer hat was wann geändert
  • Admin-Aktionen: Rollenänderungen, Benutzerverwaltung, Konfigurationsänderungen
  • Sicherheitsereignisse: Rate-Limit-Auslöser, CORS-Verletzungen, missgeformte Anfragen

Was NICHT loggen#

Niemals loggen:

  • Passwörter (auch nicht gehashte — der Hash ist ein Credential)
  • Vollständige Kreditkartennummern (nur die letzten 4 Ziffern loggen)
  • Sozialversicherungsnummern oder Regierungs-IDs
  • API-Keys oder Tokens (höchstens ein Präfix loggen: sk_live_...abc)
  • Persönliche Gesundheitsinformationen
  • Vollständige Request Bodys, die PII enthalten könnten
typescript
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys = new Set([
    "password",
    "passwordHash",
    "token",
    "secret",
    "apiKey",
    "creditCard",
    "ssn",
    "authorization",
  ]);
 
  const sanitized: Record<string, unknown> = {};
 
  for (const [key, value] of Object.entries(data)) {
    if (sensitiveKeys.has(key.toLowerCase())) {
      sanitized[key] = "[GESCHWÄRZT]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

Manipulationssichere Logs#

Wenn ein Angreifer Zugang zu deinem System erhält, ist eines der ersten Dinge, die er tun wird, die Logs zu ändern, um seine Spuren zu verwischen. Manipulationssicheres Logging macht das erkennbar:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // Der initiale Hash in der Kette
 
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
  const content = JSON.stringify(entry) + previousHash;
  const hash = crypto.createHash("sha256").update(content).digest("hex");
 
  previousHash = hash;
 
  return { ...entry, hash };
}
 
// Um die Kettenintegrität zu verifizieren:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
  let expectedPreviousHash = "GENESIS";
 
  for (const entry of entries) {
    const { hash, ...rest } = entry;
    const content = JSON.stringify(rest) + expectedPreviousHash;
    const computedHash = crypto.createHash("sha256").update(content).digest("hex");
 
    if (computedHash !== hash) {
      return false; // Kette ist gebrochen — Logs wurden manipuliert
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

Das ist dasselbe Konzept wie eine Blockchain — der Hash jedes Log-Eintrags hängt vom vorherigen Eintrag ab. Wenn jemand einen Eintrag ändert oder löscht, bricht die Kette.

Dependency Security#

Dein Code mag sicher sein. Aber was ist mit den 847 npm-Paketen in deinem node_modules? Das Supply-Chain-Problem ist real, und es ist über die Jahre schlimmer geworden.

npm audit ist das absolute Minimum#

bash
# Führe das in CI aus, lass den Build bei high/critical Schwachstellen fehlschlagen
npm audit --audit-level=high
 
# Fixe, was automatisch gefixed werden kann
npm audit fix
 
# Sieh, was du tatsächlich einziehst
npm ls --all

Aber npm audit hat Einschränkungen. Es prüft nur die npm-Advisory-Datenbank, und seine Schweregradbewertungen sind nicht immer genau. Ergänze mit zusätzlichen Tools:

Automatisiertes Dependency-Scanning#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Minor- und Patch-Updates gruppieren, um PR-Rauschen zu reduzieren
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

Die Lockfile ist ein Sicherheitswerkzeug#

Committe immer deine package-lock.json (oder pnpm-lock.yaml, oder yarn.lock). Die Lockfile pinnt exakte Versionen jeder Dependency, einschließlich transitiver. Ohne sie könnte npm install eine andere Version als die getestete einziehen — und diese andere Version könnte kompromittiert sein.

bash
# In CI ci statt install verwenden — respektiert die Lockfile strikt
npm ci

npm ci schlägt fehl, wenn die Lockfile nicht mit package.json übereinstimmt, statt sie stillschweigend zu aktualisieren. Das fängt Fälle ab, in denen jemand package.json geändert, aber vergessen hat, die Lockfile zu aktualisieren.

Vor der Installation evaluieren#

Bevor du eine Dependency hinzufügst, frage:

  1. Brauche ich das wirklich? Kann ich das in 20 Zeilen schreiben, statt ein Paket hinzuzufügen?
  2. Wie viele Downloads hat es? Niedrige Download-Zahlen sind nicht unbedingt schlecht, aber sie bedeuten weniger Augen, die den Code reviewen.
  3. Wann wurde es zuletzt aktualisiert? Ein Paket, das seit 3 Jahren nicht aktualisiert wurde, könnte ungepatchte Schwachstellen haben.
  4. Wie viele Dependencies zieht es ein? is-odd hängt von is-number ab, das von kind-of abhängt. Das sind drei Pakete, um etwas zu tun, das eine Zeile Code kann.
  5. Wer pflegt es? Ein einzelner Maintainer ist ein einzelner Kompromittierungspunkt.
typescript
// Du brauchst kein Paket dafür:
const isEven = (n: number): boolean => n % 2 === 0;
 
// Oder dafür:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Oder dafür:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

Die Pre-Deploy-Checkliste#

Das ist die tatsächliche Checkliste, die ich vor jedem Produktions-Deployment verwende. Sie ist nicht erschöpfend — Security ist nie „fertig" — aber sie fängt die Fehler ab, die am meisten zählen.

#PrüfungBestanden-KriterienPriorität
1AuthentifizierungJWTs verifiziert mit explizitem Algorithmus, Issuer und Audience. Kein alg: none.Kritisch
2Token-AblaufAccess Tokens laufen in 15 Min. oder weniger ab. Refresh Tokens rotieren bei Verwendung.Kritisch
3Token-SpeicherungRefresh Tokens in httpOnly Secure Cookies. Keine Tokens in localStorage.Kritisch
4Autorisierung auf jedem EndpointJeder Datenzugriffs-Endpoint prüft Object-Level-Berechtigungen. BOLA getestet.Kritisch
5Input-ValidierungAlle Benutzereingaben validiert mit Zod oder Äquivalent. Kein roher req.body in Queries.Kritisch
6SQL/NoSQL InjectionAlle Datenbankabfragen verwenden parametrisierte Queries oder ORM-Methoden. Keine String-Verkettung.Kritisch
7Rate LimitingAuth-Endpoints: 5/15min. Allgemeine API: 60/min. Rate-Limit-Header werden zurückgegeben.Hoch
8CORSExplizite Origin-Allowlist. Keine Wildcard mit Credentials. Preflight gecacht.Hoch
9Security HeadersCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy alle vorhanden.Hoch
10FehlerbehandlungProduktionsfehler geben generische Nachrichten zurück. Keine Stack Traces, keine SQL-Fehler exponiert.Hoch
11SecretsKeine Secrets im Code oder der Git-Historie. .env in .gitignore. Beim Start validiert.Kritisch
12Dependenciesnpm audit clean (keine high/critical). Lockfile committed. npm ci in CI.Hoch
13Nur HTTPSHSTS aktiviert mit Preload. HTTP leitet zu HTTPS weiter. Secure-Cookie-Flag gesetzt.Kritisch
14LoggingAuth-Ereignisse, Zugriff verweigert und Datenänderungen geloggt. Kein PII in Logs.Mittel
15Request-GrößenlimitsBody Parser begrenzt (1MB Standard). Datei-Uploads gedeckelt. Query-Paginierung erzwungen.Mittel
16SSRF-SchutzVom Benutzer bereitgestellte URLs validiert. Private IPs blockiert. Redirects deaktiviert oder validiert.Mittel
17Account-SperrungFehlgeschlagene Login-Versuche lösen nach 5 Versuchen Sperrung aus. Sperrung geloggt.Hoch
18Webhook-VerifizierungAlle eingehenden Webhooks mit Signaturen verifiziert. Replay-Schutz via Timestamp.Hoch
19Admin-EndpointsRollenbasierte Zugriffskontrolle auf allen Admin-Routen. Versuche geloggt.Kritisch
20Mass AssignmentUpdate-Endpoints verwenden Zod-Schema mit erlaubten Feldern. Kein roher Body-Spread.Hoch

Ich halte das als GitHub-Issue-Vorlage. Bevor ein Release getaggt wird, muss jemand im Team jede Zeile prüfen und abzeichnen. Es ist nicht glamourös, aber es funktioniert.

Der Denkwandel#

Security ist kein Feature, das du am Ende hinzufügst. Es ist kein Sprint, den du einmal im Jahr machst. Es ist eine Art, über jede Zeile Code nachzudenken, die du schreibst.

Wenn du einen Endpoint schreibst, denke: „Was, wenn jemand Daten sendet, die ich nicht erwarte?" Wenn du einen Parameter hinzufügst, denke: „Was, wenn jemand das auf die ID von jemand anderem ändert?" Wenn du eine Dependency hinzufügst, denke: „Was passiert, wenn dieses Paket nächsten Dienstag kompromittiert wird?"

Du wirst nicht alles fangen. Niemand tut das. Aber diese Checkliste durchzugehen — methodisch, vor jedem Deployment — fängt die Dinge ab, die am meisten zählen. Die schnellen Gewinne. Die offensichtlichen Lücken. Die Fehler, die aus einem schlechten Tag einen Datenbreach machen.

Baue die Gewohnheit auf. Gehe die Checkliste durch. Shippe mit Zuversicht.

Ähnliche Beiträge