Ga naar inhoud
·31 min leestijd

API-Beveiliging Best Practices: De Checklist Die Ik Bij Elk Project Doorloop

Authenticatie, autorisatie, inputvalidatie, rate limiting, CORS, secrets management en de OWASP API Top 10. Wat ik controleer voor elke productiedeployment.

Delen:X / TwitterLinkedIn

Ik heb API's geshipt die wagenwijd open stonden. Niet kwaadaardig, niet lui — ik wist gewoon niet wat ik niet wist. Een endpoint dat elk veld in het user-object teruggaf, inclusief gehashte wachtwoorden. Een rate limiter die alleen IP-adressen controleerde, waardoor iedereen achter een proxy de API kon bestoken. Een JWT-implementatie waar ik vergat de iss claim te verifieren, waardoor tokens van een compleet andere service prima werkten.

Elk van die fouten haalde productie. Ze werden allemaal ontdekt — sommige door mij, sommige door gebruikers, een door een security researcher die aardig genoeg was om me te mailen in plaats van het op Twitter te zetten.

Dit bericht is de checklist die ik heb gebouwd uit die fouten. Ik loop hem door voor elke productiedeployment. Niet omdat ik paranoide ben, maar omdat ik heb geleerd dat beveiligingsbugs degene zijn die het meest pijn doen. Een kapotte knop irriteert gebruikers. Een kapotte auth-flow lekt hun data.

Authenticatie vs Autorisatie#

Deze twee woorden worden door elkaar gebruikt in vergaderingen, in docs, zelfs in code-comments. Ze zijn niet hetzelfde.

Authenticatie beantwoordt: "Wie ben je?" Het is de loginstap. Gebruikersnaam en wachtwoord, OAuth-flow, magic link — wat je identiteit ook bewijst.

Autorisatie beantwoordt: "Wat mag je doen?" Het is de permissiestap. Mag deze gebruiker deze resource verwijderen? Hebben ze toegang tot dit admin-endpoint? Mogen ze de data van een andere gebruiker lezen?

De meest voorkomende beveiligingsbug die ik in productie-API's heb gezien is geen kapotte login-flow. Het is een ontbrekende autorisatiecontrole. De gebruiker is geauthenticeerd — ze hebben een geldig token — maar de API controleert nooit of ze bevoegd zijn om de actie uit te voeren die ze aanvragen.

JWT: Anatomie en de Fouten Die Ertoe Doen#

JWT's zijn overal. Ze worden ook overal verkeerd begrepen. Een JWT heeft drie delen, gescheiden door punten:

header.payload.signature

De header zegt welk algoritme is gebruikt. De payload bevat claims (user ID, rollen, vervaltijd). De signature bewijst dat niemand aan de eerste twee delen heeft geknoeid.

Hier is een correcte JWT-verificatie 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"], // Never allow "none"
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 seconds leeway for clock skew
    }) as TokenPayload;
 
    return payload;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, "Token expired");
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new ApiError(401, "Invalid token");
    }
    throw new ApiError(401, "Authentication failed");
  }
}

Een paar dingen om op te letten:

  1. algorithms: ["HS256"] — Dit is cruciaal. Als je het algoritme niet specificeert, kan een aanvaller een token sturen met "alg": "none" in de header en verificatie volledig overslaan. Dit is de alg: none aanval, en het heeft echte productiesystemen getroffen.

  2. issuer en audience — Zonder deze werkt een token gemaakt voor Service A ook op Service B. Als je meerdere services draait die hetzelfde secret delen (wat je niet zou moeten doen, maar mensen doen het), is dit hoe cross-service token-misbruik gebeurt.

  3. Specifieke foutafhandeling — Geef niet "invalid token" terug voor elke fout. Onderscheid maken tussen verlopen en ongeldig helpt de client weten of het moet vernieuwen of opnieuw authenticeren.

Refresh Token Rotation#

Access tokens moeten kort leven — 15 minuten is standaard. Maar je wilt niet dat gebruikers elke 15 minuten hun wachtwoord opnieuw invoeren. Daar komen refresh tokens.

Het patroon dat echt werkt in productie:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Token family voor rotatiedetectie
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token niet gevonden — ofwel verlopen of al gebruikt.
    // Als al gebruikt, is dit een potentiele replay-aanval.
    // Invalideer de hele token-familie.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Verwijder het oude token onmiddellijk — eenmalig gebruik
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Genereer nieuwe tokens
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Sla het nieuwe refresh token op met dezelfde familie
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 dagen
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Scan naar alle tokens in deze familie en verwijder ze.
  // Dit is de nucleaire optie — als iemand een refresh token hergebruikt,
  // doden we elk token in de familie, wat herauthenticatie afdwingt.
  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);
      }
    }
  }
}

Het token familie-concept is wat dit veilig maakt. Elk refresh token hoort bij een familie (aangemaakt bij login). Bij rotatie erft het nieuwe token de familie. Als een aanvaller een oud refresh token hergebruikt, detecteer je het hergebruik en dood je de hele familie. De legitieme gebruiker wordt uitgelogd, maar de aanvaller komt er niet in.

Dit debat loopt al jaren, en het antwoord is duidelijk: httpOnly cookies voor refresh tokens, altijd.

localStorage is toegankelijk voor elk JavaScript dat op je pagina draait. Als je een enkele XSS-kwetsbaarheid hebt — en op schaal krijg je die uiteindelijk — kan de aanvaller het token lezen en exfiltreren. Game over.

httpOnly cookies zijn niet toegankelijk voor JavaScript. Punt. Een XSS-kwetsbaarheid kan nog steeds verzoeken doen namens de gebruiker (omdat cookies automatisch worden meegestuurd), maar de aanvaller kan het token zelf niet stelen. Dat is een betekenisvol verschil.

typescript
// Een veilig refresh token cookie instellen
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Niet toegankelijk via JavaScript
    secure: true,       // Alleen HTTPS
    sameSite: "strict", // Geen cross-site verzoeken
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dagen
    path: "/api/auth",  // Wordt alleen naar auth-endpoints gestuurd
  });
}

Het path: "/api/auth" is een detail dat de meeste mensen missen. Standaard worden cookies naar elk endpoint op je domein gestuurd. Je refresh token hoeft niet naar /api/users of /api/products te gaan. Beperk het pad, verklein het aanvalsoppervlak.

Voor access tokens bewaar ik ze in het geheugen (een JavaScript-variabele). Niet localStorage, niet sessionStorage, niet een cookie. In het geheugen. Ze zijn kort leven (15 minuten), en wanneer de pagina wordt vernieuwd, raakt de client stil het refresh-endpoint om een nieuw token te krijgen. Ja, dit betekent een extra verzoek bij het laden van de pagina. Het is het waard.

Inputvalidatie: Vertrouw de Client Nooit#

De client is niet je vriend. De client is een vreemde die je huis binnenliep en zei "Ik mag hier zijn." Je controleert toch hun legitimatie.

Elk stuk data dat van buiten je server komt — request body, query parameters, URL-params, headers — is onbetrouwbare input. Het maakt niet uit dat je React-formulier validatie heeft. Iemand zal het omzeilen met curl.

Zod voor Type-Veilige Validatie#

Zod is het beste dat Node.js inputvalidatie is overkomen. Het geeft je runtime validatie met TypeScript-types gratis:

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Invalid email format")
    .max(254, "Email too long")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "Password must be at least 12 characters")
    .max(128, "Password too long") // Prevent bcrypt DoS
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "Password must contain uppercase, lowercase, and a number"
    ),
 
  name: z
    .string()
    .min(1, "Name is required")
    .max(100, "Name too long")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // Let op: "admin" is hier bewust geen optie.
  // Admin-roltoewijzing gaat via een apart, geprivilegieerd endpoint.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Gebruik in een Express handler
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data is volledig getypeerd als CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Een paar beveiligingsrelevante details:

  • max(128) op wachtwoord — bcrypt heeft een 72-byte inputlimiet, en sommige implementaties trunceren stilletjes. Maar nog belangrijker, als je een wachtwoord van 10MB toestaat, besteedt bcrypt aanzienlijke tijd aan het hashen ervan. Dat is een DoS-vector.
  • max(254) op email — RFC 5321 beperkt emailadressen tot 254 tekens. Alles langer is geen geldig emailadres.
  • Enum voor rol, zonder admin — Mass assignment is een van de oudste API-kwetsbaarheden. Als je de rol accepteert uit de request body zonder validatie, zal iemand "role": "admin" sturen en het beste hopen.

SQL-Injectie Is Niet Opgelost#

"Gebruik gewoon een ORM" beschermt je niet als je raw queries schrijft voor prestaties. En iedereen schrijft uiteindelijk raw queries voor prestaties.

typescript
// KWETSBAAR — string-concatenatie
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// VEILIG — geparametriseerde query
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Met Prisma ben je grotendeels veilig — maar $queryRaw kan je nog steeds bijten:

typescript
// KWETSBAAR — template literal in $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// VEILIG — Prisma.sql gebruiken voor parametrisatie
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL-Injectie#

MongoDB gebruikt geen SQL, maar het is niet immuun voor injectie. Als je niet-gesanitiseerde gebruikersinput als query-object doorgeeft, gaat het mis:

typescript
// KWETSBAAR — als req.body.username is { "$gt": "" }
// geeft dit de eerste gebruiker in de collectie terug
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// VEILIG — expliciet naar string converteren
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// BETER — eerst valideren met Zod
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

De fix is simpel: valideer inputtypes voordat ze je database-driver bereiken. Als username een string moet zijn, stel vast dat het een string is.

Path Traversal#

Als je API bestanden serveert of leest van een pad dat gebruikersinput bevat, zal path traversal je week verpesten:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normaliseer en los op naar een absoluut pad
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Cruciaal: controleer of het opgeloste pad nog steeds binnen de toegestane map is
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Access denied");
  }
 
  // Controleer of het bestand daadwerkelijk bestaat
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Zonder deze controle:
// GET /api/files?name=../../../etc/passwd
// lost op naar /etc/passwd

Het path.resolve + startsWith patroon is de juiste aanpak. Probeer niet handmatig ../ te strippen — er zijn te veel encodingstrucs (..%2F, ..%252F, ....//) die je regex zullen omzeilen.

Rate Limiting#

Zonder rate limiting is je API een all-you-can-eat buffet voor bots. Brute force-aanvallen, credential stuffing, resource-uitputting — rate limiting is de eerste verdediging tegen allemaal.

Token Bucket vs Sliding Window#

Token bucket: Je hebt een emmer die N tokens bevat. Elk verzoek kost een token. Tokens worden aangevuld met een vast tempo. Als de emmer leeg is, wordt het verzoek afgewezen. Dit staat bursts toe — als de emmer vol is, kun je N verzoeken tegelijk doen.

Sliding window: Tel verzoeken binnen een bewegend tijdvenster. Voorspelbaarder, moeilijker om doorheen te bursten.

Ik gebruik sliding window voor het meeste omdat het gedrag makkelijker te beredeneren en uit te leggen is aan het team:

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();
 
  // Verwijder entries buiten het venster
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Tel entries in het venster
  multi.zcard(key);
 
  // Voeg het huidige verzoek toe (we verwijderen het als het over de limiet is)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Stel vervaltijd in op de key
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Redis transaction failed");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // Over de limiet — verwijder de entry die we net hebben toegevoegd
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

Gelaagde Rate Limits#

Een globale rate limit is niet genoeg. Verschillende endpoints hebben verschillende risicoprofielen:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Auth endpoints — strakke limieten, brute force doelwit
  "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 },
 
  // Data reads — genereuzer
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Data writes — gematigd
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Globale 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}`;
}

Let op: geauthenticeerde gebruikers worden beperkt op user-ID, niet op IP. Dit is belangrijk omdat veel legitieme gebruikers IP's delen (bedrijfsnetwerken, VPN's, mobiele providers). Als je alleen op IP beperkt, blokkeer je hele kantoren.

Rate Limit Headers#

Vertel de client altijd wat er aan de hand is:

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: "Too many requests",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

CORS-Configuratie#

CORS is waarschijnlijk het meest verkeerd begrepen beveiligingsmechanisme in webontwikkeling. De helft van de Stack Overflow-antwoorden over CORS is "stel gewoon Access-Control-Allow-Origin: * in en het werkt." Dat klopt technisch. Het is ook hoe je je API openstelt voor elke kwaadaardige site op het internet.

Wat CORS Eigenlijk Doet (en Niet Doet)#

CORS is een browser-mechanisme. Het vertelt de browser of JavaScript van Origin A de response van Origin B mag lezen. Dat is het.

Wat CORS niet doet:

  • Het beschermt je API niet tegen curl, Postman of server-naar-server verzoeken
  • Het authenticeert verzoeken niet
  • Het versleutelt niets
  • Het voorkomt CSRF niet op zichzelf (hoewel het helpt in combinatie met andere mechanismen)

Wat CORS wel doet:

  • Het voorkomt dat kwaadaardige-website.com fetch-verzoeken doet naar jouw-api.com en het antwoord leest in de browser van de gebruiker
  • Het voorkomt dat het JavaScript van de aanvaller data exfiltreert via de geauthenticeerde sessie van het slachtoffer

De Wildcard-Val#

typescript
// GEVAARLIJK — staat elke website toe om je API-responses te lezen
app.use(cors({ origin: "*" }));
 
// OOK GEVAARLIJK — dit is een veelvoorkomende "dynamische" aanpak die gewoon * is met extra stappen
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Staat alles toe
    },
  })
);

Het probleem met * is dat het je API-responses leesbaar maakt voor elk JavaScript op elke pagina. Als je API gebruikersdata teruggeeft en de gebruiker is geauthenticeerd via cookies, kan elke website die de gebruiker bezoekt die data lezen.

Nog erger: Access-Control-Allow-Origin: * kan niet gecombineerd worden met credentials: true. Dus als je cookies nodig hebt (voor auth), kun je letterlijk de wildcard niet gebruiken. Maar ik heb mensen dit zien proberen te omzeilen door de Origin-header terug te reflecteren — wat gelijkwaardig is aan * met credentials, het slechtste van twee werelden.

De Juiste Configuratie#

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) => {
      // Sta verzoeken zonder origin toe (mobiele apps, curl, server-naar-server)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    },
    credentials: true, // Sta cookies toe
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Cache preflight voor 24 uur
  })
);

Belangrijke beslissingen:

  • Expliciete origin set, geen regex. Regexes zijn lastig — yourapp.com zou evilyourapp.com kunnen matchen als je regex niet goed verankerd is.
  • credentials: true omdat we httpOnly cookies gebruiken voor refresh tokens.
  • maxAge: 86400 — Preflight-verzoeken (OPTIONS) voegen latentie toe. De browser vertellen om het CORS-resultaat 24 uur te cachen vermindert onnodige round trips.
  • exposedHeaders — Standaard stelt de browser slechts een handvol "simpele" response-headers bloot aan JavaScript. Als je wilt dat de client je rate limit headers kan lezen, moet je ze expliciet blootstellen.

Preflight-Verzoeken#

Wanneer een verzoek niet "simpel" is (het gebruikt een niet-standaard header, een niet-standaard methode, of een niet-standaard content type), stuurt de browser eerst een OPTIONS-verzoek om toestemming te vragen. Dit is de preflight.

Als je CORS-configuratie OPTIONS niet afhandelt, zullen preflight-verzoeken falen, en het werkelijke verzoek wordt nooit verstuurd. De meeste CORS-bibliotheken handelen dit automatisch af, maar als je een framework gebruikt dat dit niet doet, moet je het zelf afhandelen:

typescript
// Handmatige preflight-afhandeling (de meeste frameworks doen dit voor je)
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 zijn de goedkoopste beveiligingsverbetering die je kunt maken. Het zijn response-headers die de browser vertellen om beveiligingsfeatures in te schakelen. De meeste zijn een enkele regel configuratie, en ze beschermen tegen hele klassen aanvallen.

De Headers Die Ertoe Doen#

typescript
import helmet from "helmet";
 
// Een regel. Dit is de snelste beveiligingswinst in elke Express-app.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Nodig voor veel CSS-in-JS oplossingen
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 jaar
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Wat elke header doet:

Content-Security-Policy (CSP) — De krachtigste security header. Het vertelt de browser precies welke bronnen zijn toegestaan voor scripts, stijlen, afbeeldingen, lettertypen, etc. Als een aanvaller een <script> tag injecteert die laadt van evil.com, blokkeert CSP het. Dit is de meest effectieve verdediging tegen XSS.

Strict-Transport-Security (HSTS) — Vertelt de browser om altijd HTTPS te gebruiken, zelfs als de gebruiker http:// typt. De preload directive laat je je domein indienen bij de ingebouwde HSTS-lijst van de browser, zodat zelfs het eerste verzoek geforceerd naar HTTPS gaat.

X-Frame-Options — Voorkomt dat je site wordt ingebed in een iframe. Dit stopt clickjacking-aanvallen waarbij een aanvaller je pagina overlaagt met onzichtbare elementen. Helmet stelt dit standaard in op SAMEORIGIN. De moderne vervanging is frame-ancestors in CSP.

X-Content-Type-Options: nosniff — Voorkomt dat de browser het MIME-type van een response raadt (sniffing). Zonder dit, als je een bestand serveert met het verkeerde Content-Type, zou de browser het als JavaScript kunnen uitvoeren.

Referrer-Policy — Bepaalt hoeveel URL-informatie wordt meegestuurd in de Referer-header. strict-origin-when-cross-origin stuurt de volledige URL voor same-origin verzoeken maar alleen de origin voor cross-origin verzoeken. Dit voorkomt het lekken van gevoelige URL-parameters naar derden.

Je Headers Testen#

Na het deployen, controleer je score op securityheaders.com. Mik op een A+ beoordeling. Het kost ongeveer vijf minuten configuratie om daar te komen.

Je kunt headers ook programmatisch verifieren:

typescript
import { describe, it, expect } from "vitest";
 
describe("Security headers", () => {
  it("should include all required security headers", 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 verwijdert dit
  });
});

De x-powered-by controle is subtiel maar belangrijk. Express stelt standaard X-Powered-By: Express in, waarmee aanvallers precies weten welk framework je gebruikt. Helmet verwijdert het.

Secrets Management#

Deze zou vanzelfsprekend moeten zijn, maar ik zie het nog steeds in pull requests: API-keys, databasewachtwoorden en JWT-secrets hardcoded in bronbestanden. Of gecommit in .env-bestanden die niet in .gitignore stonden. Zodra het in de git-geschiedenis staat, staat het daar voor altijd, zelfs als je het bestand in de volgende commit verwijdert.

De Regels#

  1. Commit nooit secrets naar git. Niet in code, niet in .env, niet in configuratiebestanden, niet in Docker Compose-bestanden, niet in "alleen voor testen" comments.

  2. Gebruik .env.example als template. Het documenteert welke environment variables nodig zijn, zonder werkelijke waarden:

bash
# .env.example — commit dit
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 — NOOIT committen
# Staat in .gitignore
  1. Valideer environment variables bij het opstarten. Wacht niet tot een verzoek een endpoint raakt dat de database-URL nodig heeft. Faal snel:
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
  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("Invalid environment variables:");
    console.error(result.error.format());
    process.exit(1); // Start niet met slechte configuratie
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. Gebruik een secret manager in productie. Environment variables werken voor simpele setups, maar ze hebben beperkingen: ze zijn zichtbaar in proceslijsten, ze blijven in het geheugen, en ze kunnen lekken via foutlogs.

Voor productiesystemen, gebruik een echte secret manager:

  • AWS Secrets Manager of SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (als je iets wilt dat over alle clouds werkt)

Het patroon is hetzelfde ongeacht welke je gebruikt: de applicatie haalt secrets op bij het opstarten vanuit de secret manager, niet vanuit environment variables.

  1. Roteer secrets regelmatig. Als je al twee jaar hetzelfde JWT-secret gebruikt, is het tijd om te roteren. Implementeer key-rotatie: ondersteun meerdere geldige signing keys tegelijkertijd, teken nieuwe tokens met de nieuwe key, verifieer met zowel oud als nieuw, en pensioneer de oude key nadat alle bestaande tokens zijn verlopen.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Alleen de actieve key tekent nieuwe tokens
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Geeft alle geldige keys terug
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Probeer de volgende key
    }
  }
 
  throw new ApiError(401, "Invalid 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, // Voeg key-ID toe aan de header
  });
}

OWASP API Security Top 10#

De OWASP API Security Top 10 is de industriestandaard lijst van API-kwetsbaarheden. Het wordt periodiek bijgewerkt, en elk item op de lijst is iets dat ik in echte codebases heb gezien. Laat me elk doorlopen.

API1: Broken Object Level Authorization (BOLA)#

De meest voorkomende API-kwetsbaarheid. De gebruiker is geauthenticeerd, maar de API controleert niet of ze toegang hebben tot het specifieke object dat ze opvragen.

typescript
// KWETSBAAR — elke geauthenticeerde gebruiker kan de data van elke gebruiker bekijken
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// OPGELOST — controleer of de gebruiker zijn eigen data bekijkt (of admin is)
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: "Access denied" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

De kwetsbare versie is overal. Het passeert elke auth-controle — de gebruiker heeft een geldig token — maar het verifieert niet of ze bevoegd zijn om deze specifieke resource te bekijken. Verander de ID in de URL, en je krijgt de data van iemand anders.

API2: Broken Authentication#

Zwakke loginmechanismen, ontbrekende MFA, tokens die nooit verlopen, wachtwoorden opgeslagen in platte tekst. Dit omvat de authenticatielaag zelf.

De oplossing is alles wat we bespraken in de authenticatiesectie: sterke wachtwoordvereisten, bcrypt met voldoende rounds, kort levende access tokens, refresh token-rotatie, accountvergrendeling na mislukte pogingen.

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 locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // Verhoog mislukte pogingen
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Zelfde foutmelding voor beide gevallen — onthul niet of het emailadres bestaat
    throw new ApiError(401, "Invalid email or password");
  }
 
  // Reset mislukte pogingen bij succesvolle login
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

De opmerking over "zelfde foutmelding" is belangrijk. Als je API "user not found" teruggeeft voor ongeldige emails en "wrong password" voor geldige emails met verkeerde wachtwoorden, vertel je een aanvaller welke emails in je systeem bestaan.

API3: Broken Object Property Level Authorization#

Meer data teruggeven dan nodig, of gebruikers toestaan properties te wijzigen die ze niet zouden mogen.

typescript
// KWETSBAAR — geeft het hele user-object terug, inclusief interne velden
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // Response bevat: passwordHash, internalNotes, billingId, ...
});
 
// OPGELOST — expliciete allowlist van teruggegeven velden
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,
  });
});

Geef nooit hele database-objecten terug. Kies altijd de velden die je wilt blootstellen. Dit geldt ook voor writes — spread niet de hele request body in je update-query:

typescript
// KWETSBAAR — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // Aanvaller stuurt: { "role": "admin", "verified": true }
});
 
// OPGELOST — kies toegestane velden
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#

Je API is een resource. CPU, geheugen, bandbreedte, databaseverbindingen — ze zijn allemaal eindig. Zonder limieten kan een enkele client ze allemaal uitputten.

Dit gaat verder dan rate limiting. Het omvat:

typescript
// Beperk request body-grootte
app.use(express.json({ limit: "1mb" }));
 
// Beperk query-complexiteit
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),
});
 
// Beperk bestandsuploadgrootte
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("Invalid file type"));
    }
  },
});
 
// Timeout voor langlopende verzoeken
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Request timeout" });
  });
  next();
});

API5: Broken Function Level Authorization#

Anders dan BOLA. Dit gaat over het benaderen van functies (endpoints) waartoe je geen toegang zou moeten hebben, niet objecten. Het klassieke voorbeeld: een gewone gebruiker die admin-endpoints ontdekt.

typescript
// Middleware die role-based access controleert
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Not authenticated" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // Log de poging — dit kan een aanval zijn
      logger.warn("Unauthorized access attempt", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "Insufficient permissions" });
    }
 
    next();
  };
}
 
// Toepassen op routes
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);

Vertrouw niet op het verbergen van endpoints. "Security through obscurity" is geen beveiliging. Zelfs als de admin-panel URL nergens gelinkt is, zal iemand /api/admin/users vinden door fuzzing.

API6: Unrestricted Access to Sensitive Business Flows#

Geautomatiseerd misbruik van legitieme bedrijfsfunctionaliteit. Denk aan: bots die beperkte voorraadartikelen kopen, geautomatiseerde accountaanmaak voor spam, scrapen van productprijzen.

De mitigaties zijn contextspecifiek: CAPTCHA's, device fingerprinting, gedragsanalyse, step-up authenticatie voor gevoelige operaties. Er is geen one-size-fits-all codefragment.

API7: Server Side Request Forgery (SSRF)#

Als je API URL's ophaalt die door de gebruiker zijn opgegeven (webhooks, profielfoto-URL's, linkpreviews), kan een aanvaller je server interne bronnen laten opvragen:

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, "Invalid URL");
  }
 
  // Sta alleen HTTP(S) toe
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Only HTTP(S) URLs are allowed");
  }
 
  // Los de hostname op en controleer of het een prive-IP is
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Internal addresses are not allowed");
    }
  }
 
  // Haal nu op met een timeout en groottelimiet
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Volg geen redirects (ze kunnen naar interne IP's leiden)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Belangrijke details: los de DNS eerst op en controleer het IP voordat je het verzoek doet. Blokkeer redirects — een aanvaller kan een URL hosten die doorverwijst naar http://169.254.169.254/ (AWS metadata-endpoint) om je URL-level controle te omzeilen.

API8: Security Misconfiguration#

Standaardinloggegevens ongewijzigd, onnodige HTTP-methoden ingeschakeld, uitgebreide foutmeldingen in productie, directory listing ingeschakeld, CORS verkeerd geconfigureerd. Dit is de "je vergat de deur op slot te doen" categorie.

typescript
// Lek geen stack traces in productie
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("Unhandled error", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // Generieke foutmelding — onthul geen interne details
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // Voeg een request-ID toe voor debugging
    });
  } else {
    // In ontwikkeling, toon de volledige fout
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Schakel onnodige HTTP-methoden uit
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "Method not allowed" });
  }
  next();
});

API9: Improper Inventory Management#

Je hebt v2 van de API gedeployd maar vergeten v1 uit te schakelen. Of er is een /debug/ endpoint dat nuttig was tijdens ontwikkeling en nog steeds draait in productie. Of een staging-server die publiek toegankelijk is met productiedata.

Dit is geen code-fix — het is operationele discipline. Onderhoud een lijst van alle API-endpoints, alle gedeployde versies en alle omgevingen. Gebruik geautomatiseerde scanning om blootgestelde services te vinden. Sluit af wat je niet nodig hebt.

API10: Unsafe Consumption of APIs#

Je API verbruikt API's van derden. Valideer je hun responses? Wat gebeurt er als een webhook-payload van Stripe eigenlijk van een aanvaller afkomstig is?

typescript
import crypto from "crypto";
 
// Verifieer Stripe webhook-handtekeningen
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;
 
  // Weiger oude timestamps (voorkom replay-aanvallen)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // 5 minuten tolerantie
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Verifieer altijd handtekeningen op webhooks. Valideer altijd de structuur van API-responses van derden. Stel altijd timeouts in op uitgaande verzoeken. Vertrouw nooit data alleen omdat het van "een vertrouwde partner" komt.

Audit Logging#

Wanneer er iets misgaat — en dat zal het — zijn audit logs hoe je uitzoekt wat er is gebeurd. Maar logging is een tweesnijdend zwaard. Log te weinig en je bent blind. Log te veel en je creert een privacy-aansprakelijkheid.

Wat Te 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>; // Extra context
  requestId: string;        // Voor correlatie met applicatielogs
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Schrijf naar een aparte, append-only datastore
  // Dit mag NIET dezelfde database zijn die je applicatie gebruikt
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // Voor kritieke acties, schrijf ook naar een onveranderlijk extern log
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Log deze gebeurtenissen:

  • Authenticatie: logins, logouts, mislukte pogingen, token-vernieuwingen
  • Autorisatie: toegang-geweigerd gebeurtenissen (dit zijn vaak aanvalsindicatoren)
  • Datawijzigingen: creates, updates, deletes — wie veranderde wat, wanneer
  • Admin-acties: rolwijzigingen, gebruikersbeheer, configuratiewijzigingen
  • Beveiligingsgebeurtenissen: rate limit-triggers, CORS-schendingen, misvormde verzoeken

Wat NIET Te Loggen#

Log nooit:

  • Wachtwoorden (zelfs gehashte — de hash is een credential)
  • Volledige creditcardnummers (log alleen de laatste 4 cijfers)
  • BSN-nummers of overheids-ID's
  • API-keys of tokens (log hoogstens een prefix: sk_live_...abc)
  • Persoonlijke gezondheidsinformatie
  • Volledige request bodies die PII kunnen bevatten
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] = "[REDACTED]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

Tamper-Evident Logs#

Als een aanvaller toegang krijgt tot je systeem, is een van de eerste dingen die ze doen het wijzigen van de logs om hun sporen te wissen. Tamper-evident logging maakt dit detecteerbaar:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // De initiele hash in de keten
 
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 };
}
 
// Om de integriteit van de keten te verifieren:
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; // Keten is gebroken — logs zijn gewijzigd
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

Dit is hetzelfde concept als een blockchain — de hash van elke logentry hangt af van de vorige entry. Als iemand een entry wijzigt of verwijdert, breekt de keten.

Dependency Security#

Je code is misschien veilig. Maar hoe zit het met de 847 npm-pakketten in je node_modules? Het supply chain-probleem is echt, en het is door de jaren heen erger geworden.

npm audit Is het Absolute Minimum#

bash
# Draai dit in CI, laat de build falen bij hoge/kritieke kwetsbaarheden
npm audit --audit-level=high
 
# Fix wat automatisch gefixt kan worden
npm audit fix
 
# Bekijk wat je daadwerkelijk binnenhaalt
npm ls --all

Maar npm audit heeft beperkingen. Het controleert alleen de npm advisory-database, en de ernstbeoordelingen zijn niet altijd accuraat. Voeg extra tools toe:

Geautomatiseerde 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"
    # Groepeer minor en patch updates om PR-ruis te verminderen
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

Het Lockfile Is een Beveiligingstool#

Commit altijd je package-lock.json (of pnpm-lock.yaml, of yarn.lock). Het lockfile pint exacte versies van elke dependency, inclusief transitieve. Zonder het lockfile kan npm install een andere versie binnenhalen dan wat je hebt getest — en die andere versie kan gecompromitteerd zijn.

bash
# In CI, gebruik ci in plaats van install — het respecteert het lockfile strikt
npm ci

npm ci faalt als het lockfile niet overeenkomt met package.json, in plaats van het stilletjes bij te werken. Dit vangt gevallen op waar iemand package.json heeft gewijzigd maar vergat het lockfile bij te werken.

Evalueer Voordat Je Installeert#

Voordat je een dependency toevoegt, vraag je af:

  1. Heb ik dit echt nodig? Kan ik dit in 20 regels schrijven in plaats van een pakket toe te voegen?
  2. Hoeveel downloads heeft het? Lage downloadaantallen zijn niet per se slecht, maar het betekent minder ogen die de code beoordelen.
  3. Wanneer was de laatste update? Een pakket dat 3 jaar niet is bijgewerkt kan ongepatchte kwetsbaarheden hebben.
  4. Hoeveel dependencies trekt het binnen? is-odd hangt af van is-number dat afhangt van kind-of. Dat zijn drie pakketten om iets te doen wat een regel code kan.
  5. Wie onderhoudt het? Een enkele maintainer is een enkel punt van compromis.
typescript
// Je hebt hier geen pakket voor nodig:
const isEven = (n: number): boolean => n % 2 === 0;
 
// Of dit:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Of dit:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

De Pre-Deploy Checklist#

Dit is de werkelijke checklist die ik gebruik voor elke productiedeployment. Het is niet uitputtend — beveiliging is nooit "klaar" — maar het vangt de fouten die het meest ertoe doen.

#ControleSlaagcriteriaPrioriteit
1AuthenticatieJWT's geverifieerd met expliciet algoritme, issuer en audience. Geen alg: none.Kritiek
2Token-vervaltijdAccess tokens verlopen in 15 min of minder. Refresh tokens roteren bij gebruik.Kritiek
3TokenopslagRefresh tokens in httpOnly secure cookies. Geen tokens in localStorage.Kritiek
4Autorisatie op elk endpointElk data-access endpoint controleert object-level permissies. BOLA getest.Kritiek
5InputvalidatieAlle gebruikersinput gevalideerd met Zod of equivalent. Geen raw req.body in queries.Kritiek
6SQL/NoSQL-injectieAlle database-queries gebruiken geparametriseerde queries of ORM-methoden. Geen string-concatenatie.Kritiek
7Rate limitingAuth endpoints: 5/15min. Algemene API: 60/min. Rate limit headers teruggegeven.Hoog
8CORSExpliciete origin-allowlist. Geen wildcard met credentials. Preflight gecached.Hoog
9Security headersCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy allemaal aanwezig.Hoog
10FoutafhandelingProductiefouten geven generieke berichten. Geen stack traces, geen SQL-fouten blootgesteld.Hoog
11SecretsGeen secrets in code of git-geschiedenis. .env in .gitignore. Gevalideerd bij opstarten.Kritiek
12Dependenciesnpm audit schoon (geen hoog/kritiek). Lockfile gecommit. npm ci in CI.Hoog
13Alleen HTTPSHSTS ingeschakeld met preload. HTTP redirect naar HTTPS. Secure cookie flag ingesteld.Kritiek
14LoggingAuth-events, toegang-geweigerd en datamutaties gelogd. Geen PII in logs.Medium
15VerzoekgroottelimietenBody parser beperkt (1MB standaard). Bestandsuploads begrensd. Query-paginering afgedwongen.Medium
16SSRF-beschermingDoor gebruikers opgegeven URL's gevalideerd. Prive-IP's geblokkeerd. Redirects uitgeschakeld of gevalideerd.Medium
17AccountvergrendelingMislukte loginpogingen triggeren vergrendeling na 5 pogingen. Vergrendeling gelogd.Hoog
18Webhook-verificatieAlle inkomende webhooks geverifieerd met handtekeningen. Replay-bescherming via timestamp.Hoog
19Admin-endpointsRole-based access control op alle admin-routes. Pogingen gelogd.Kritiek
20Mass assignmentUpdate-endpoints gebruiken Zod-schema met allowlisted velden. Geen raw body spread.Hoog

Ik bewaar dit als een GitHub issue-template. Voor het taggen van een release moet iemand in het team elke rij controleren en aftekenen. Het is niet glamoureus, maar het werkt.

De Mentaliteitsverandering#

Beveiliging is geen feature die je aan het einde toevoegt. Het is geen sprint die je een keer per jaar doet. Het is een manier van denken over elke regel code die je schrijft.

Wanneer je een endpoint schrijft, denk: "Wat als iemand data stuurt die ik niet verwacht?" Wanneer je een parameter toevoegt, denk: "Wat als iemand dit verandert naar de ID van iemand anders?" Wanneer je een dependency toevoegt, denk: "Wat gebeurt er als dit pakket volgende dinsdag wordt gecompromitteerd?"

Je vangt niet alles. Niemand doet dat. Maar het doorlopen van deze checklist — methodisch, voor elke deployment — vangt de dingen die het meest ertoe doen. De makkelijke overwinningen. De voor de hand liggende gaten. De fouten die een slechte dag in een datalek veranderen.

Bouw de gewoonte op. Loop de checklist door. Ship met vertrouwen.

Gerelateerde artikelen