Gå till innehåll
·30 min läsning

Bästa praxis för API-säkerhet: Checklistan jag kör på varje projekt

Autentisering, auktorisering, inputvalidering, rate limiting, CORS, hemlighetshantering och OWASP API Top 10. Vad jag kontrollerar före varje produktionsdeploy.

Dela:X / TwitterLinkedIn

Jag har levererat API:er som stod vidöppna. Inte av illvilja, inte av lathet — jag visste bara inte vad jag inte visste. En endpoint som returnerade varje fält i användarobjektet, inklusive hashade lösenord. En rate limiter som bara kontrollerade IP-adresser, vilket innebar att vem som helst bakom en proxy kunde hamra API:et. En JWT-implementation där jag glömde att verifiera iss-anspråket, så tokens från en helt annan tjänst fungerade alldeles utmärkt.

Alla dessa misstag nådde produktion. Alla upptäcktes — vissa av mig, vissa av användare, ett av en säkerhetsforskare som var vänlig nog att maila mig istället för att posta det på Twitter.

Det här inlägget är checklistan jag byggde utifrån dessa misstag. Jag går igenom den före varje produktionsdeploy. Inte för att jag är paranoid, utan för att jag har lärt mig att säkerhetsbuggar är de som gör mest ont. En trasig knapp irriterar användare. Ett trasigt auth-flöde läcker deras data.

Autentisering vs Auktorisering#

Dessa två ord används omväxlande i möten, i dokumentation, till och med i kodkommentarer. De är inte samma sak.

Autentisering besvarar: "Vem är du?" Det är inloggningssteget. Användarnamn och lösenord, OAuth-flöde, magisk länk — vad som helst som bevisar din identitet.

Auktorisering besvarar: "Vad får du göra?" Det är behörighetssteget. Får den här användaren ta bort den här resursen? Får de komma åt den här admin-endpointen? Får de läsa en annan användares data?

Den vanligaste säkerhetsbuggen jag har sett i produktions-API:er är inte ett trasigt inloggningsflöde. Det är en saknad auktoriseringskontroll. Användaren är autentiserad — de har en giltig token — men API:et kontrollerar aldrig om de har behörighet att utföra den åtgärd de begär.

JWT: Anatomi och misstagen som spelar roll#

JWT:er finns överallt. De missförstås också överallt. En JWT har tre delar, separerade med punkter:

header.payload.signature

Headern anger vilken algoritm som användes. Payloaden innehåller anspråk (användar-ID, roller, utgångstid). Signaturen bevisar att ingen har manipulerat de två första delarna.

Här är en korrekt JWT-verifiering i 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");
  }
}

Några saker att notera:

  1. algorithms: ["HS256"] — Detta är kritiskt. Om du inte anger algoritmen kan en angripare skicka en token med "alg": "none" i headern och helt hoppa över verifieringen. Detta är alg: none-attacken, och den har drabbat riktiga produktionssystem.

  2. issuer och audience — Utan dessa fungerar en token skapad för Tjänst A på Tjänst B. Om du kör flera tjänster som delar samma hemlighet (vilket du inte borde, men folk gör det), är det så här cross-service token-missbruk sker.

  3. Specifik felhantering — Returnera inte "invalid token" för varje fel. Att skilja mellan utgångna och ogiltiga tokens hjälper klienten att veta om den ska förnya eller återautentisera.

Refresh Token Rotation#

Access tokens bör vara kortlivade — 15 minuter är standard. Men du vill inte att användare ska ange sitt lösenord var 15:e minut. Det är där refresh tokens kommer in.

Mönstret som faktiskt fungerar i produktion:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Token family for rotation detection
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token not found — either expired or already used.
    // If already used, this is a potential replay attack.
    // Invalidate the entire token family.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Delete the old token immediately — single use only
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Generate new tokens
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Store the new refresh token with the same family
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 days
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Scan for all tokens in this family and delete them.
  // This is the nuclear option — if someone replays a refresh token,
  // we kill every token in the family, forcing re-authentication.
  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);
      }
    }
  }
}

Konceptet med token family är det som gör detta säkert. Varje refresh token tillhör en familj (skapad vid inloggning). När du roterar ärver den nya token familjen. Om en angripare spelar om en gammal refresh token upptäcker du återanvändningen och dödar hela familjen. Den legitima användaren loggas ut, men angriparen kommer inte in.

Denna debatt har pågått i flera år, och svaret är tydligt: httpOnly cookies för refresh tokens, alltid.

localStorage är tillgängligt för all JavaScript som körs på din sida. Om du har en enda XSS-sårbarhet — och i stor skala kommer du så småningom att ha det — kan angriparen läsa token och exfiltrera den. Game over.

httpOnly cookies är inte tillgängliga för JavaScript. Punkt. En XSS-sårbarhet kan fortfarande göra förfrågningar å användarens vägnar (eftersom cookies skickas automatiskt), men angriparen kan inte stjäla själva token. Det är en meningsfull skillnad.

typescript
// Setting a secure refresh token cookie
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Not accessible via JavaScript
    secure: true,       // HTTPS only
    sameSite: "strict", // No cross-site requests
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
    path: "/api/auth",  // Only sent to auth endpoints
  });
}

path: "/api/auth" är en detalj de flesta missar. Som standard skickas cookies till varje endpoint på din domän. Din refresh token behöver inte skickas till /api/users eller /api/products. Begränsa sökvägen, minska angreppsytan.

För access tokens håller jag dem i minnet (en JavaScript-variabel). Inte localStorage, inte sessionStorage, inte en cookie. I minnet. De är kortlivade (15 minuter), och när sidan uppdateras gör klienten en tyst förfrågan till refresh-endpointen för att hämta en ny. Ja, detta innebär en extra förfrågan vid sidladdning. Det är värt det.

Inputvalidering: Lita aldrig på klienten#

Klienten är inte din vän. Klienten är en främling som klev in i ditt hus och sa "Jag får vara här." Du kontrollerar deras legitimation ändå.

Varje bit data som kommer utifrån din server — request body, query-parametrar, URL-parametrar, headers — är opålitlig input. Det spelar ingen roll att ditt React-formulär har validering. Någon kommer att kringgå det med curl.

Zod för typsäker validering#

Zod är det bästa som hänt Node.js inputvalidering. Det ger dig runtime-validering med TypeScript-typer på köpet:

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"),
  // Note: "admin" is intentionally not an option here.
  // Admin role assignment goes through a separate, privileged endpoint.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Usage in an 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 fully typed as CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Några säkerhetsrelevanta detaljer:

  • max(128) på lösenord — bcrypt har en 72-byte inputgräns, och vissa implementationer trunkerar tyst. Men viktigare är att om du tillåter ett 10MB-lösenord kommer bcrypt att spendera betydande tid på att hasha det. Det är en DoS-vektor.
  • max(254) på e-post — RFC 5321 begränsar e-postadresser till 254 tecken. Allt längre är inte en giltig e-postadress.
  • Enum för roll, utan admin — Mass assignment är en av de äldsta API-sårbarheterna. Om du accepterar rollen från request body utan att validera den kommer någon att skicka "role": "admin" och hoppas på det bästa.

SQL Injection är inte löst#

"Använd bara en ORM" skyddar dig inte om du skriver råa queries för prestanda. Och alla skriver råa queries för prestanda till slut.

typescript
// VULNERABLE — string concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// SAFE — parameterized query
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Med Prisma är du mestadels säker — men $queryRaw kan fortfarande bita dig:

typescript
// VULNERABLE — template literal in $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// SAFE — using Prisma.sql for parameterization
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL Injection#

MongoDB använder inte SQL, men det är inte immunt mot injection. Om du skickar osaniterad användarinput som ett query-objekt går det fel:

typescript
// VULNERABLE — if req.body.username is { "$gt": "" }
// this returns the first user in the collection
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// SAFE — explicitly coerce to string
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// BETTER — validate with Zod first
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

Lösningen är enkel: validera inputtyper innan de når din databasdrivrutin. Om username ska vara en sträng, kontrollera att det är en sträng.

Path Traversal#

Om ditt API serverar filer eller läser från en sökväg som inkluderar användarinput kommer path traversal att förstöra din vecka:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normalize and resolve to an absolute path
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Critical: verify the resolved path is still within the allowed directory
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Access denied");
  }
 
  // Verify the file actually exists
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Without this check:
// GET /api/files?name=../../../etc/passwd
// resolves to /etc/passwd

Mönstret path.resolve + startsWith är det korrekta tillvägagångssättet. Försök inte att strippra ../ manuellt — det finns för många kodningstekniker (..%2F, ..%252F, ....//) som kommer att kringgå ditt regex.

Rate Limiting#

Utan rate limiting är ditt API en ät-så-mycket-du-vill-buffé för bottar. Brute force-attacker, credential stuffing, resursutmattning — rate limiting är det första försvaret mot allt detta.

Token Bucket vs Sliding Window#

Token bucket: Du har en hink som rymmer N tokens. Varje förfrågan kostar en token. Tokens fylls på med en fast hastighet. Om hinken är tom avvisas förfrågan. Detta tillåter bursts — om hinken är full kan du göra N förfrågningar omedelbart.

Sliding window: Räkna förfrågningar inom ett rörligt tidsfönster. Mer förutsägbart, svårare att bursta igenom.

Jag använder sliding window för det mesta eftersom beteendet är lättare att resonera kring och förklara för teamet:

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();
 
  // Remove entries outside the window
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Count entries in the window
  multi.zcard(key);
 
  // Add the current request (we'll remove it if over limit)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Set expiry on the 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 limit — remove the entry we just added
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

Skiktade Rate Limits#

En enda global rate limit räcker inte. Olika endpoints har olika riskprofiler:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Auth endpoints — tight limits, brute force target
  "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 — more generous
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Data writes — moderate
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Global 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}`;
}

Notera: autentiserade användare begränsas per användar-ID, inte IP. Detta är viktigt eftersom många legitima användare delar IP:er (företagsnätverk, VPN:er, mobiloperatörer). Om du bara begränsar per IP kommer du att blockera hela kontor.

Rate Limit Headers#

Berätta alltid för klienten vad som pågår:

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-konfiguration#

CORS är förmodligen den mest missförstådda säkerhetsmekanismen inom webbutveckling. Hälften av Stack Overflow-svaren om CORS är "sätt bara Access-Control-Allow-Origin: * så fungerar det." Det är tekniskt sant. Det är också så du öppnar ditt API för varje skadlig webbplats på internet.

Vad CORS faktiskt gör (och inte gör)#

CORS är en webbläsar-mekanism. Den berättar för webbläsaren om JavaScript från Ursprung A tillåts läsa svaret från Ursprung B. Det är allt.

Vad CORS inte gör:

  • Det skyddar inte ditt API från curl, Postman eller server-till-server-förfrågningar
  • Det autentiserar inte förfrågningar
  • Det krypterar ingenting
  • Det förhindrar inte CSRF i sig (även om det hjälper i kombination med andra mekanismer)

Vad CORS gör:

  • Det förhindrar malicious-website.com från att göra fetch-förfrågningar till your-api.com och läsa svaret i användarens webbläsare
  • Det förhindrar angriparens JavaScript från att exfiltrera data genom offrets autentiserade session

Wildcard-fällan#

typescript
// DANGEROUS — allows any website to read your API responses
app.use(cors({ origin: "*" }));
 
// ALSO DANGEROUS — this is a common "dynamic" approach that's just * with extra steps
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Allows everything
    },
  })
);

Problemet med * är att det gör dina API-svar läsbara av vilken JavaScript som helst på vilken sida som helst. Om ditt API returnerar användardata och användaren är autentiserad via cookies kan vilken webbplats som helst som användaren besöker läsa den datan.

Ännu värre: Access-Control-Allow-Origin: * kan inte kombineras med credentials: true. Så om du behöver cookies (för auth) kan du bokstavligen inte använda wildcard. Men jag har sett folk försöka kringgå detta genom att reflektera Origin-headern tillbaka — vilket är ekvivalent med * med credentials, det värsta av båda världar.

Den korrekta konfigurationen#

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) => {
      // Allow requests with no origin (mobile apps, curl, server-to-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, // Allow cookies
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Cache preflight for 24 hours
  })
);

Viktiga beslut:

  • Explicit origin-uppsättning, inte ett regex. Regex är knepiga — yourapp.com kan matcha evilyourapp.com om ditt regex inte är förankrat korrekt.
  • credentials: true eftersom vi använder httpOnly cookies för refresh tokens.
  • maxAge: 86400 — Preflight-förfrågningar (OPTIONS) lägger till latens. Att säga åt webbläsaren att cacha CORS-resultatet i 24 timmar minskar onödiga rundturer.
  • exposedHeaders — Som standard exponerar webbläsaren bara en handfull "enkla" svarshuvuden för JavaScript. Om du vill att klienten ska kunna läsa dina rate limit-headers måste du uttryckligen exponera dem.

Preflight-förfrågningar#

När en förfrågan inte är "enkel" (den använder en icke-standard header, en icke-standard metod eller en icke-standard content type) skickar webbläsaren först en OPTIONS-förfrågan för att be om tillåtelse. Detta är preflighten.

Om din CORS-konfiguration inte hanterar OPTIONS kommer preflight-förfrågningar att misslyckas, och den faktiska förfrågan skickas aldrig. De flesta CORS-bibliotek hanterar detta automatiskt, men om du använder ett ramverk som inte gör det behöver du hantera det:

typescript
// Manual preflight handling (most frameworks do this for you)
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();
});

Säkerhetshuvuden#

Säkerhetshuvuden är den billigaste säkerhetsförbättringen du kan göra. De är svarshuvuden som berättar för webbläsaren att aktivera säkerhetsfunktioner. De flesta av dem är en enda rad konfiguration, och de skyddar mot hela klasser av attacker.

Huvudena som spelar roll#

typescript
import helmet from "helmet";
 
// One line. This is the fastest security win in any Express app.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Needed for many CSS-in-JS solutions
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 year
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Vad varje header gör:

Content-Security-Policy (CSP) — Den mest kraftfulla säkerhetsheadern. Den berättar för webbläsaren exakt vilka källor som är tillåtna för skript, stilar, bilder, typsnitt etc. Om en angripare injicerar en <script>-tagg som laddar från evil.com blockerar CSP det. Detta är det mest effektiva försvaret mot XSS.

Strict-Transport-Security (HSTS) — Berättar för webbläsaren att alltid använda HTTPS, även om användaren skriver http://. Direktivet preload låter dig skicka in din domän till webbläsarens inbyggda HSTS-lista, så att även den första förfrågan tvingas till HTTPS.

X-Frame-Options — Förhindrar att din webbplats bäddas in i en iframe. Detta stoppar clickjacking-attacker där en angripare lägger osynliga element ovanpå din sida. Helmet sätter detta till SAMEORIGIN som standard. Den moderna ersättaren är frame-ancestors i CSP.

X-Content-Type-Options: nosniff — Förhindrar webbläsaren från att gissa (sniffa) MIME-typen för ett svar. Utan detta, om du serverar en fil med fel Content-Type, kan webbläsaren köra den som JavaScript.

Referrer-Policy — Styr hur mycket URL-information som skickas i Referer-headern. strict-origin-when-cross-origin skickar hela URL:en för same-origin-förfrågningar men bara ursprunget för cross-origin-förfrågningar. Detta förhindrar att känsliga URL-parametrar läcker till tredje parter.

Testa dina headers#

Efter deploy, kontrollera ditt betyg på securityheaders.com. Sikta på A+-betyg. Det tar ungefär fem minuters konfiguration att nå dit.

Du kan även verifiera headers programmatiskt:

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 removes this
  });
});

Kontrollen av x-powered-by är subtil men viktig. Express sätter X-Powered-By: Express som standard, vilket berättar för angripare exakt vilket ramverk du använder. Helmet tar bort det.

Hemlighetshantering#

Det här borde vara uppenbart, men jag ser det fortfarande i pull requests: API-nycklar, databaslösenord och JWT-hemligheter hårdkodade i källfiler. Eller committade i .env-filer som inte fanns i .gitignore. När det väl finns i git-historiken finns det där för alltid, även om du raderar filen i nästa commit.

Reglerna#

  1. Committa aldrig hemligheter till git. Inte i kod, inte i .env, inte i konfigurationsfiler, inte i Docker Compose-filer, inte i "bara för testning"-kommentarer.

  2. Använd .env.example som mall. Den dokumenterar vilka miljövariabler som behövs, utan att innehålla faktiska värden:

bash
# .env.example — commit this
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 — NEVER commit this
# Listed in .gitignore
  1. Validera miljövariabler vid uppstart. Vänta inte tills en förfrågan träffar en endpoint som behöver databas-URL:en. Fallera tidigt:
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); // Don't start with bad config
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. Använd en hemlighethanterare i produktion. Miljövariabler fungerar för enkla uppsättningar, men de har begränsningar: de är synliga i processlistor, de finns kvar i minnet, och de kan läcka via felloggar.

För produktionssystem, använd en riktig hemlighethanterare:

  • AWS Secrets Manager eller SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (om du vill ha något som fungerar över alla moln)

Mönstret är detsamma oavsett vilken du använder: applikationen hämtar hemligheter vid uppstart från hemlighethanteraren, inte från miljövariabler.

  1. Rotera hemligheter regelbundet. Om du har använt samma JWT-hemlighet i två år är det dags att rotera. Implementera nyckelrotation: stöd flera giltiga signeringsnycklar samtidigt, signera nya tokens med den nya nyckeln, verifiera med både gammal och ny, och pensionera den gamla nyckeln efter att alla befintliga tokens har gått ut.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Only the active key signs new tokens
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Returns all valid keys
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Try the next 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, // Include key ID in the header
  });
}

OWASP API Security Top 10#

OWASP API Security Top 10 är branschstandardlistan över API-sårbarheter. Den uppdateras periodiskt, och varje punkt på listan är något jag har sett i riktiga kodbaser. Låt mig gå igenom var och en.

API1: Broken Object Level Authorization (BOLA)#

Den vanligaste API-sårbarheten. Användaren är autentiserad, men API:et kontrollerar inte om de har tillgång till det specifika objekt de begär.

typescript
// VULNERABLE — any authenticated user can access any user's data
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// FIXED — verify the user is accessing their own data (or is an admin)
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);
});

Den sårbara versionen finns överallt. Den klarar varje auth-kontroll — användaren har en giltig token — men den verifierar inte att de har behörighet att komma åt just den här resursen. Ändra ID:t i URL:en, och du får någon annans data.

API2: Broken Authentication#

Svaga inloggningsmekanismer, saknad MFA, tokens som aldrig går ut, lösenord lagrade i klartext. Detta täcker autentiseringslagret i sig.

Lösningen är allt vi diskuterade i autentiseringssektionen: starka lösenordskrav, bcrypt med tillräckliga rundor, kortlivade access tokens, refresh token rotation, kontolåsning efter misslyckade försök.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
 
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))) {
    // Increment failed attempts
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Same error message for both cases — don't reveal whether the email exists
    throw new ApiError(401, "Invalid email or password");
  }
 
  // Reset failed attempts on successful login
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

Kommentaren om "samma felmeddelande" är viktig. Om ditt API returnerar "användaren hittades inte" för ogiltiga e-postadresser och "fel lösenord" för giltiga e-postadresser med fel lösenord, berättar du för en angripare vilka e-postadresser som finns i ditt system.

API3: Broken Object Property Level Authorization#

Att returnera mer data än nödvändigt, eller att låta användare ändra egenskaper de inte borde.

typescript
// VULNERABLE — returns the entire user object, including internal fields
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // Response includes: passwordHash, internalNotes, billingId, ...
});
 
// FIXED — explicit allowlist of returned fields
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,
  });
});

Returnera aldrig hela databasobjekt. Välj alltid de fält du vill exponera. Detta gäller även skrivningar — sprid inte hela request body i din uppdateringsquery:

typescript
// VULNERABLE — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // Attacker sends: { "role": "admin", "verified": true }
});
 
// FIXED — pick allowed fields
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#

Ditt API är en resurs. CPU, minne, bandbredd, databasanslutningar — de är alla ändliga. Utan begränsningar kan en enda klient uttömma dem alla.

Detta går bortom rate limiting. Det inkluderar:

typescript
// Limit request body size
app.use(express.json({ limit: "1mb" }));
 
// Limit query complexity
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),
});
 
// Limit file upload size
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 long-running requests
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Request timeout" });
  });
  next();
});

API5: Broken Function Level Authorization#

Skiljer sig från BOLA. Detta handlar om att komma åt funktioner (endpoints) du inte borde ha tillgång till, inte objekt. Det klassiska exemplet: en vanlig användare som upptäcker admin-endpoints.

typescript
// Middleware that checks role-based access
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 the attempt — this might be an attack
      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();
  };
}
 
// Apply to 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);

Förlita dig inte på att dölja endpoints. "Security through obscurity" är inte säkerhet. Även om admin-panelens URL inte är länkad någonstans kommer någon att hitta /api/admin/users genom fuzzing.

API6: Unrestricted Access to Sensitive Business Flows#

Automatiserat missbruk av legitim affärsfunktionalitet. Tänk: bottar som köper produkter med begränsat lager, automatiserat kontoskapande för spam, scraping av produktpriser.

Motåtgärderna är kontextspecifika: CAPTCHAs, enhetsfingerprintning, beteendeanalys, stegad autentisering för känsliga operationer. Det finns inget universellt kodexempel.

API7: Server Side Request Forgery (SSRF)#

Om ditt API hämtar URL:er som tillhandahålls av användaren (webhooks, profilbilds-URL:er, länkförhandsgranskningar) kan en angripare få din server att begära interna resurser:

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");
  }
 
  // Only allow HTTP(S)
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Only HTTP(S) URLs are allowed");
  }
 
  // Resolve the hostname and check if it's a private IP
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Internal addresses are not allowed");
    }
  }
 
  // Now fetch with a timeout and size limit
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Don't follow redirects (they could redirect to internal IPs)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Viktiga detaljer: resolva DNS:en först och kontrollera IP:n innan du gör förfrågan. Blockera omdirigeringar — en angripare kan hosta en URL som omdirigerar till http://169.254.169.254/ (AWS metadata endpoint) för att kringgå din URL-nivåkontroll.

API8: Security Misconfiguration#

Standarduppgifter som inte ändrats, onödiga HTTP-metoder aktiverade, utförliga felmeddelanden i produktion, kataloglistning aktiverad, CORS felkonfigurerat. Detta är kategorin "du glömde att låsa dörren".

typescript
// Don't leak stack traces in production
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") {
    // Generic error message — don't reveal internals
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // Include a request ID for debugging
    });
  } else {
    // In development, show the full error
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Disable unnecessary HTTP methods
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#

Du deployade v2 av API:et men glömde att stänga ner v1. Eller så finns det en /debug/-endpoint som var användbar under utveckling och fortfarande körs i produktion. Eller en staging-server som är publikt tillgänglig med produktionsdata.

Detta är inte en kodfix — det är operativ disciplin. Upprätthåll en lista över alla API-endpoints, alla deployade versioner och alla miljöer. Använd automatiserad skanning för att hitta exponerade tjänster. Döda det du inte behöver.

API10: Unsafe Consumption of APIs#

Ditt API konsumerar tredjepartsAPI:er. Validerar du deras svar? Vad händer om en webhook-payload från Stripe faktiskt kommer från en angripare?

typescript
import crypto from "crypto";
 
// Verify Stripe webhook signatures
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;
 
  // Reject old timestamps (prevent replay attacks)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // 5 minute tolerance
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Verifiera alltid signaturer på webhooks. Validera alltid strukturen på tredjepartsAPI-svar. Sätt alltid timeouts på utgående förfrågningar. Lita aldrig på data bara för att den kom från "en betrodd partner."

Granskningsloggning#

När något går fel — och det kommer att hända — är granskningsloggar hur du tar reda på vad som hände. Men loggning är ett tveeggat svärd. Logga för lite och du är blind. Logga för mycket och du skapar ett integritetsansvar.

Vad som ska loggas#

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>; // Additional context
  requestId: string;        // For correlating with application logs
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Write to a separate, append-only data store
  // This should NOT be the same database your application uses
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // For critical actions, also write to an immutable external log
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Logga dessa händelser:

  • Autentisering: inloggningar, utloggningar, misslyckade försök, token-förnyelser
  • Auktorisering: nekat åtkomst-händelser (dessa är ofta attackindikatorer)
  • Dataändringar: skapanden, uppdateringar, borttagningar — vem ändrade vad, när
  • Admin-åtgärder: rolländringar, användarhantering, konfigurationsändringar
  • Säkerhetshändelser: rate limit-utlösningar, CORS-överträdelser, felformade förfrågningar

Vad som INTE ska loggas#

Logga aldrig:

  • Lösenord (inte ens hashade — hashen är en uppgift)
  • Fullständiga kreditkortsnummer (logga bara de sista 4 siffrorna)
  • Personnummer eller statliga ID:n
  • API-nycklar eller tokens (logga som mest ett prefix: sk_live_...abc)
  • Personlig hälsoinformation
  • Fullständiga request bodies som kan innehålla PII
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;
}

Manipuleringssäkra loggar#

Om en angripare får tillgång till ditt system är en av de första sakerna de gör att modifiera loggarna för att dölja sina spår. Manipuleringssäker loggning gör detta detekterbart:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // The initial hash in the chain
 
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 };
}
 
// To verify the chain integrity:
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; // Chain is broken — logs have been tampered with
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

Detta är samma koncept som en blockchain — varje loggposts hash beror på den föregående posten. Om någon modifierar eller raderar en post bryts kedjan.

Beroendesäkerhet#

Din kod kan vara säker. Men vad med de 847 npm-paketen i din node_modules? Leveranskedjeproblemet är verkligt, och det har blivit värre med åren.

npm audit är det absoluta minimumet#

bash
# Run this in CI, fail the build on high/critical vulnerabilities
npm audit --audit-level=high
 
# Fix what can be auto-fixed
npm audit fix
 
# See what you're actually pulling in
npm ls --all

Men npm audit har begränsningar. Det kontrollerar bara npm:s rådgivningsdatabas, och dess allvarlighetsbedömningar är inte alltid korrekta. Lägg till ytterligare verktyg:

Automatiserad beroendesanning#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Group minor and patch updates to reduce PR noise
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

Lockfilen är ett säkerhetsverktyg#

Committa alltid din package-lock.json (eller pnpm-lock.yaml, eller yarn.lock). Lockfilen pinner exakta versioner av varje beroende, inklusive transitiva. Utan den kan npm install dra in en annan version än den du testade — och den annorlunda versionen kan vara komprometterad.

bash
# In CI, use ci instead of install — it respects the lockfile strictly
npm ci

npm ci misslyckas om lockfilen inte matchar package.json, istället för att tyst uppdatera den. Detta fångar fall där någon ändrade package.json men glömde att uppdatera lockfilen.

Utvärdera innan du installerar#

Innan du lägger till ett beroende, fråga:

  1. Behöver jag verkligen detta? Kan jag skriva det på 20 rader istället för att lägga till ett paket?
  2. Hur många nedladdningar har det? Låga nedladdningssiffror är inte nödvändigtvis dåligt, men det innebär färre ögon som granskar koden.
  3. När uppdaterades det senast? Ett paket som inte har uppdaterats på 3 år kan ha opatchade sårbarheter.
  4. Hur många beroenden drar det in? is-odd beror på is-number som beror på kind-of. Det är tre paket för att göra något en kodrad kan klara.
  5. Vem underhåller det? En enda underhållare är en enda kompromettpunkt.
typescript
// You don't need a package for this:
const isEven = (n: number): boolean => n % 2 === 0;
 
// Or this:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Or this:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

Checklistan före deploy#

Detta är den faktiska checklistan jag använder före varje produktionsdeploy. Den är inte uttömmande — säkerhet är aldrig "klart" — men den fångar de misstag som spelar mest roll.

#KontrollGodkänt-kriterierPrioritet
1AutentiseringJWT:er verifierade med explicit algoritm, utfärdare och publik. Ingen alg: none.Kritisk
2TokenutgångAccess tokens går ut inom 15 min eller mindre. Refresh tokens roterar vid användning.Kritisk
3TokenlagringRefresh tokens i httpOnly secure cookies. Inga tokens i localStorage.Kritisk
4Auktorisering på varje endpointVarje dataåtkomst-endpoint kontrollerar objektnivåbehörigheter. BOLA testat.Kritisk
5InputvalideringAll användarinput validerad med Zod eller motsvarande. Ingen rå req.body i queries.Kritisk
6SQL/NoSQL injectionAlla databasfrågor använder parametriserade queries eller ORM-metoder. Ingen strängkonkatenering.Kritisk
7Rate limitingAuth-endpoints: 5/15min. Allmänt API: 60/min. Rate limit-headers returneras.Hög
8CORSExplicit origin-vitlista. Ingen wildcard med credentials. Preflight cachad.Hög
9SäkerhetshuvudenCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy alla närvarande.Hög
10FelhanteringProduktionsfel returnerar generiska meddelanden. Inga stackspår, inga SQL-fel exponerade.Hög
11HemligheterInga hemligheter i kod eller git-historik. .env i .gitignore. Validerade vid uppstart.Kritisk
12Beroendennpm audit rent (inga höga/kritiska). Lockfil committad. npm ci i CI.Hög
13Enbart HTTPSHSTS aktiverat med preload. HTTP omdirigerar till HTTPS. Secure cookie-flagga satt.Kritisk
14LoggningAuth-händelser, nekat åtkomst och datamutationer loggade. Ingen PII i loggar.Medel
15Begränsning av förfrågningsstorlekBody parser begränsad (1MB standard). Filuppladdningar cappade. Query-paginering genomdriven.Medel
16SSRF-skyddAnvändarangivna URL:er validerade. Privata IP:er blockerade. Omdirigeringar inaktiverade eller validerade.Medel
17KontolåsningMisslyckade inloggningsförsök utlöser låsning efter 5 försök. Låsning loggad.Hög
18Webhook-verifieringAlla inkommande webhooks verifierade med signaturer. Replay-skydd via tidsstämpel.Hög
19Admin-endpointsRollbaserad åtkomstkontroll på alla admin-rutter. Försök loggade.Kritisk
20Mass assignmentUppdateringsendpoints använder Zod-schema med vitlistade fält. Ingen rå body-spridning.Hög

Jag har detta som en GitHub issue-mall. Innan en release taggas måste någon i teamet kontrollera varje rad och signera. Det är inte glamoröst, men det fungerar.

Tankesättsförändringen#

Säkerhet är inte en funktion du lägger till i slutet. Det är inte en sprint du gör en gång om året. Det är ett sätt att tänka om varje kodrad du skriver.

När du skriver en endpoint, tänk: "Vad händer om någon skickar data jag inte förväntar mig?" När du lägger till en parameter, tänk: "Vad händer om någon ändrar detta till någon annans ID?" När du lägger till ett beroende, tänk: "Vad händer om det här paketet komprometteras nästa tisdag?"

Du kommer inte att fånga allt. Ingen gör det. Men att gå igenom den här checklistan — metodiskt, före varje deploy — fångar det som spelar mest roll. De enkla vinsterna. De uppenbara hålen. Misstagen som förvandlar en dålig dag till en dataläcka.

Bygg vanan. Kör checklistan. Skeppa med tillförsikt.

Relaterade inlägg