security-toolkit/skills/api-hardening/SKILL.md
API security hardening patterns. Use when implementing rate limiting, input validation, CORS configuration, API key management, request throttling, or protecting endpoints from abuse. Covers defense-in-depth strategies for REST APIs with practical implementations for Express, FastAPI, and serverless, oriented around the OWASP API Security Top 10:2023.
npx skillsauth add jamditis/claude-skills-journalism api-hardeningInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Defense-in-depth patterns for protecting APIs from abuse, injection attacks, and data leakage. Recipes are oriented around the OWASP API Security Top 10:2023 and were last verified on 2026-05-08.
Security knowledge ages on a 6-12 month half-life. The recipes below were last verified on 2026-05-08; they may be stale by the time you read this. Before applying any pattern in this skill, fan out research scoped to the API surface or web defense being added so the recipes are interpreted against current authoritative sources, not against this file's snapshot.
Run the 4-angle research below by default. Skip ONLY when ALL of these hold:
Research skipped because <reason> note in your response."I think I know" / "moving fast" / "user wants this done quickly" / "already familiar" are NOT valid skip reasons. The whole point of this preamble is that future-you should not trust this skill body's defaults until current state is checked.
Each subagent returns ≤300 words of bullets with citations. Dispatch all 4 in a single message so they run concurrently.
Angle 1 — Authoritative standards. Have NIST / OWASP / IETF (RFCs and Internet-Drafts) / W3C / CISA published anything new about the API surface or web defense being added in the last 6-12 months? Look for: spec finalizations, deprecations, replacement specs, RFC publications, draft revisions, NIST SP updates, OWASP project version bumps. Cite by document number + publication date.
Angle 2 — Active exploitation. What's actively being exploited that targets the API surface or web defense being added? Pull from: CISA Known Exploited Vulnerabilities (KEV) catalog (filter to last 6-12 months), recent CVE / GHSA entries with high CVSS or in-the-wild exploitation, breach postmortems and incident reports (CSRB, vendor RCAs, security-vendor research). Surface CWE patterns dominating recent KEV adds. Cite by CVE number + advisory URL.
Angle 3 — Tooling and library state. Are the libraries this skill recommends still current? What are the latest major versions in the relevant package registry (npm / PyPI / RubyGems / crates.io)? Have any been deprecated, replaced, or merged into another project? Have any flipped a secure default? Look up current versions in: registry.npmjs.org, pypi.org, rubygems.org, crates.io, pkg.go.dev. Cite by package + version + release date.
Angle 4 — Practitioner discourse. What are practitioners and security teams talking about in the last 6 months? Pull from: OWASP Cheat Sheet Series (last-modified date matters), GitHub Security Lab posts, vendor security blogs (Cloudflare, Fastly, Snyk, Datadog, Wiz, GitGuardian), conference talks (Black Hat, DEF CON, OWASP Global AppSec, USENIX Security), SANS ISC, Krebs, recent OWASP project re-releases. Surface the patterns being adopted and the anti-patterns being called out. Cite by post URL + author + date.
After the 4 returns land, write a 1-paragraph "current state for the API surface or web defense being added, as of <today's date>" that names:
If the synthesis flags drift in this skill body's recipes (e.g., a spec finalized after 2026-05-08, a library now deprecated, a default flipped), call that out explicitly in your response and override the skill body where they conflict. The synthesis wins. The skill body is scaffolding, not scripture.
If subagents are not available in your runtime, the same shape applies in-line: do 4 sequential targeted searches (web search for standards, KEV catalog lookup, package registry version checks, recent cheat-sheet diff). Land the same 1-paragraph synthesis. Cost goes up; the protection does not change.
The active edition for API-specific threat modeling is the OWASP API Security Top 10:2023 (https://owasp.org/API-Security/editions/2023/en/0x00-header/). The general OWASP Top 10:2025 (released late 2025, succeeding the 2021 edition) covers web applications more broadly; for an API surface, the 2023 API-specific list is the right framing.
The 2023 categories, and which sections of this skill speak to each:
secure-auth skill for the auth primitive itself; this skill covers rate limits, request size, and timeout protections that flank auth endpoints.For the broader web context, OWASP Top 10:2025 reorders the 2021 list. Notable shifts: A03:2025 "Software Supply Chain Failures" absorbs the old 2021 A06 "Vulnerable and Outdated Components" — that 2021 category is dissolved into the supply-chain category. A09:2025 is "Security Logging and Alerting Failures" (previously "Logging and Monitoring"). The 2025 ordering: A01 Broken Access Control / A02 Security Misconfiguration / A03 Software Supply Chain Failures / A04 Cryptographic Failures / A05 Injection / A06 Insecure Design / A07 Authentication Failures / A08 Software or Data Integrity Failures / A09 Security Logging and Alerting Failures / A10 Mishandling of Exceptional Conditions.
Real 2023-2024 incidents that anchor the patterns in this skill. Cite these when explaining "why" to stakeholders.
<script> and <link rel="stylesheet"> needs a Subresource Integrity hash and a strict CSP; "trusted CDN" is not a guarantee.Maps to API4:2023 (unrestricted resource consumption) and API6:2023 (sensitive business flows).
Without rate limiting:
Distributed credential stuffing defeats per-IP limits — attackers rotate through residential proxy networks and one IP rarely hits the threshold. Pair per-IP limits with per-account quotas, behavioral signals (impossible-travel, device fingerprint anomalies), and a WAF in front for botnet patterns.
Library versions current as of 2026-05-08: express-rate-limit and rate-limit-redis — verify in registry.npmjs.org before pinning.
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();
// General API rate limit
const apiLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
skip: (req) => {
// Skip rate limiting for health checks
return req.path === '/health';
}
});
// Strict limit for auth endpoints
const authLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts, please try again in 15 minutes' },
keyGenerator: (req) => {
// Rate limit by IP + email to prevent distributed attacks
return `${req.ip}-${req.body?.email || 'unknown'}`;
}
});
// Very strict limit for password reset
const passwordResetLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 requests per hour
message: { error: 'Too many password reset requests' }
});
// Apply limiters
app.use('/api/', apiLimiter);
app.use('/auth/login', authLimiter);
app.use('/auth/forgot-password', passwordResetLimiter);
// Redis-based sliding window rate limiter
class SlidingWindowRateLimiter {
constructor(redisClient, options = {}) {
this.redis = redisClient;
this.windowMs = options.windowMs || 60000; // 1 minute default
this.maxRequests = options.maxRequests || 100;
this.keyPrefix = options.keyPrefix || 'ratelimit';
}
async isAllowed(identifier) {
const now = Date.now();
const windowStart = now - this.windowMs;
const key = `${this.keyPrefix}:${identifier}`;
// Remove old entries and count recent ones
const multi = this.redis.multi();
multi.zRemRangeByScore(key, 0, windowStart);
multi.zCard(key);
multi.zAdd(key, { score: now, value: `${now}-${Math.random()}` });
multi.expire(key, Math.ceil(this.windowMs / 1000));
const results = await multi.exec();
const requestCount = results[1];
return {
allowed: requestCount < this.maxRequests,
remaining: Math.max(0, this.maxRequests - requestCount - 1),
resetAt: now + this.windowMs
};
}
}
// Express middleware
function createRateLimitMiddleware(limiter) {
return async (req, res, next) => {
const identifier = req.ip;
const result = await limiter.isAllowed(identifier);
res.setHeader('X-RateLimit-Limit', limiter.maxRequests);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', result.resetAt);
if (!result.allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
};
}
// Different limits based on tier
const tierLimits = {
free: { windowMs: 60000, max: 10 },
pro: { windowMs: 60000, max: 100 },
enterprise: { windowMs: 60000, max: 1000 }
};
async function apiKeyRateLimiter(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Look up API key
const keyData = await db.query(
'SELECT user_id, tier, revoked FROM api_keys WHERE key_hash = $1',
[hashApiKey(apiKey)]
);
if (keyData.rows.length === 0 || keyData.rows[0].revoked) {
return res.status(401).json({ error: 'Invalid API key' });
}
const { user_id, tier } = keyData.rows[0];
const limits = tierLimits[tier] || tierLimits.free;
// Rate limit by user, not by key (prevents key rotation abuse)
const limiter = new SlidingWindowRateLimiter(redisClient, {
...limits,
keyPrefix: 'apikey'
});
const result = await limiter.isAllowed(user_id);
res.setHeader('X-RateLimit-Limit', limits.max);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', result.resetAt);
if (!result.allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
req.userId = user_id;
next();
}
Endpoints that expand a relationship or graph one hop at a time (DNA relatives, contact networks, follower fan-out, organization-membership lookups) are the textbook case for per-account quotas in addition to per-IP. A single compromised account inside a per-IP budget can still walk the graph and exfiltrate data on every other connected account, which is what amplified the 23andMe credential-stuffing breach.
// Per-account daily budget for graph-expansion endpoints
const graphLimiter = new SlidingWindowRateLimiter(redisClient, {
windowMs: 24 * 60 * 60 * 1000, // 24 hours
maxRequests: 500, // tune to product norms
keyPrefix: 'graph'
});
async function graphTraversalQuota(req, res, next) {
const result = await graphLimiter.isAllowed(req.userId);
if (!result.allowed) {
return res.status(429).json({ error: 'Daily graph quota exceeded' });
}
next();
}
app.get('/api/relatives', requireAuth, graphTraversalQuota, listRelatives);
app.get('/api/contacts/expand', requireAuth, graphTraversalQuota, expandContacts);
Maps to API3:2023 (broken object property level authorization) and API10:2023 (unsafe consumption of APIs).
zod is on the 4.x line as of 2026-05-08 (4.4.3 current; verify before pinning). Patterns below work on v4; if you're still on 3.x, safeParse and the schema builders below are unchanged.
Untrusted input also has a deserialization dimension — see the dedicated section below. CWE-502 (deserialization of untrusted data) dominated the CISA KEV catalog in 2024-2025, so JSON-only at trust boundaries plus schema validation is the baseline.
const { z } = require('zod');
// Define schemas
const createUserSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(12).max(128),
name: z.string().min(1).max(100).optional()
});
const updateProfileSchema = z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
website: z.string().url().optional().or(z.literal(''))
});
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20)
});
// Middleware factory
function validate(schema) {
return (req, res, next) => {
const result = schema.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
}))
});
}
req.validated = result.data;
next();
};
}
// Usage
app.post('/users', validate(createUserSchema), async (req, res) => {
const { email, password, name } = req.validated;
// Data is validated and typed
});
Trusted Types is a related browser-side defense — it forces dangerous DOM sinks to consume policy-vetted objects instead of strings, which kills entire classes of DOM-based XSS. As of 2026-05-08 Trusted Types is cross-browser (Chromium since 2020, Firefox 148, Safari 26.0; caniuse global usage ~89%), so require-trusted-types-for 'script' is now a realistic CSP directive rather than a Chrome-only nice-to-have. See the security headers section.
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const validator = require('validator');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
// HTML sanitization (when you MUST allow some HTML)
function sanitizeHtml(dirty) {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false
});
}
// String sanitization
function sanitizeString(str) {
if (typeof str !== 'string') return '';
return str
.trim()
.slice(0, 10000) // Max length
.replace(/[\x00-\x1F\x7F]/g, ''); // Remove control characters
}
// SQL-safe identifier (for dynamic column names)
function sanitizeIdentifier(str) {
// Only allow alphanumeric and underscores
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(str)) {
throw new Error('Invalid identifier');
}
return str;
}
// Filename sanitization
function sanitizeFilename(filename) {
return filename
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/\.{2,}/g, '.')
.slice(0, 255);
}
Maps to OWASP Top 10:2025 A05 (injection). MOVEit CVE-2023-34362 is the canonical 2023 example: a pre-auth SQLi in a file-transfer product, weaponized by Cl0p across thousands of organizations (per https://nvd.nist.gov/vuln/detail/CVE-2023-34362). Don't assume "internal" or "non-user-facing" endpoints are safe to skip parameterization on.
// BAD: String interpolation
const query = `SELECT * FROM users WHERE id = ${userId}`;
// BAD: String concatenation
const query = 'SELECT * FROM users WHERE id = ' + userId;
// BAD: Template literals with user input
const query = `SELECT * FROM users WHERE name = '${name}'`;
// GOOD: Parameterized queries (PostgreSQL)
const result = await db.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
// GOOD: Parameterized queries (MySQL)
const result = await db.query(
'SELECT * FROM users WHERE id = ?',
[userId]
);
// GOOD: Query builders (Knex)
const users = await knex('users')
.where('id', userId)
.first();
// GOOD: ORMs (Prisma)
const user = await prisma.user.findUnique({
where: { id: userId }
});
// When you need dynamic column names (rare)
const allowedColumns = ['name', 'email', 'created_at'];
const sortColumn = allowedColumns.includes(req.query.sort)
? req.query.sort
: 'created_at';
const query = `SELECT * FROM users ORDER BY ${sortColumn}`; // Safe because allowlisted
CWE-78 (OS command injection) dominated the CISA KEV catalog over 2024-2025 — 14 entries in 2024 and 18 in 2025, more than classic SQL injection. The Ivanti Connect Secure chain (CVE-2024-21887) is the marquee example. The rule is simple: never compose a shell command from untrusted input. Use the language's argv-list spawn primitives with the shell disabled, and allowlist any path or filename that flows into a process.
Avoid the shell-execution primitives in the Node child-process module when any argument can be influenced by user input — they pass the full string through /bin/sh -c and any metacharacter (;, &, |, backtick, $()) becomes injection. Prefer the argv-list spawn family with the shell disabled, which is the default.
const { exec, execFile, spawn } = require('child_process');
// DO NOT USE: shell metacharacters in `filename` execute as commands
exec(`convert ${filename} out.png`, (err, stdout) => { /* ... */ });
// DO NOT USE: same problem with execSync
require('child_process').execSync(`convert ${filename} out.png`);
// GOOD: argv-list with the shell disabled (the default for execFile/spawn)
execFile('convert', [filename, 'out.png'], (err, stdout) => { /* ... */ });
// GOOD: spawn with explicit shell:false plus an allowlist on the filename
const safeFilename = /^[A-Za-z0-9_.-]+\.(png|jpg|jpeg)$/.test(filename)
? filename
: null;
if (!safeFilename) throw new Error('Invalid filename');
const child = spawn('convert', [safeFilename, 'out.png'], { shell: false });
Avoid the OS shell-execution primitives — the os.system call, subprocess.run with shell=True, subprocess.Popen with shell=True, and os.popen — when any argument can be influenced by user input. Use subprocess.run with an argv list and shell=False (the default), and allowlist any filename or path that crosses the trust boundary.
import subprocess
import re
from pathlib import Path
# DO NOT USE: os.system passes the full string through the shell
import os
os.system(f"convert {filename} out.png")
# DO NOT USE: shell=True is the same vulnerability
subprocess.run(f"convert {filename} out.png", shell=True, check=True)
# DO NOT USE: os.popen also goes through the shell
os.popen(f"convert {filename} out.png").read()
# GOOD: argv list, shell disabled (the default), with input allowlisting
if not re.fullmatch(r"[A-Za-z0-9_.-]+\.(png|jpg|jpeg)", filename):
raise ValueError("Invalid filename")
subprocess.run(
["convert", filename, "out.png"],
check=True,
shell=False, # explicit; this is also the default
timeout=30,
)
# GOOD: when the input is a path, resolve and confirm it's inside an allowed root
allowed_root = Path("/var/app/uploads").resolve()
candidate = (allowed_root / filename).resolve()
if not candidate.is_relative_to(allowed_root):
raise ValueError("Path traversal attempt")
CWE-502 (deserialization of untrusted data) was the second-most-common KEV CWE in 2024-2025 — 11 entries in 2024 and 14 in 2025. The native binary serializers and unrestricted YAML loaders treat the input as a program: arbitrary code runs at parse time, before any of your validation logic. The rule is: JSON-only at trust boundaries, and validate the parsed JSON with a schema (Zod / Pydantic) before using it.
Avoid the native binary deserialization primitive (the pickle loaders) and the unsafe YAML loader (the bare yaml.load call without Loader=SafeLoader) on any input that crosses a trust boundary — both will execute arbitrary objects on parse. The same warning applies to the marshal loader.
import json
import pickle
import yaml
from pydantic import BaseModel
# DO NOT USE: pickle.loads on untrusted input executes arbitrary code
obj = pickle.loads(request.body)
# DO NOT USE: yaml.load with no SafeLoader is a code-execution sink
config = yaml.load(request.body) # equivalent to yaml.Loader
# DO NOT USE: marshal.loads has the same untrusted-input hazard as pickle
import marshal
obj = marshal.loads(request.body)
# GOOD: JSON parsing returns plain data (dict / list / str / number / bool / None)
class CreateUserRequest(BaseModel):
email: str
password: str
data = json.loads(request.body) # safe parse
user = CreateUserRequest.model_validate(data) # schema-validated
# GOOD: when YAML is genuinely required, use SafeLoader (or yaml.safe_load)
config = yaml.safe_load(request.body)
In Node, the equivalent risk lives in any library that accepts a "this is a serialized object" payload (the node-serialize package, older funcster-style packages, eval-based JSON5 forks). Stick to JSON.parse and validate with Zod.
In Java, avoid the binary deserialization primitive (ObjectInputStream.readObject) on untrusted input — Jackson, GSON, or another JSON library is the safe choice. In .NET, avoid the legacy binary formatter (BinaryFormatter, NetDataContractSerializer, LosFormatter, SoapFormatter) — Microsoft has flagged it for removal precisely because of CWE-502 (per https://learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-security-guide). Use System.Text.Json for cross-trust-boundary input.
// DO NOT USE: a third-party deserializer on untrusted input
const obj = require('node-serialize').unserialize(request.body);
// GOOD: JSON.parse + Zod schema validation
const data = JSON.parse(request.body);
const parsed = createUserSchema.safeParse(data);
if (!parsed.success) {
return res.status(400).json({ error: 'Validation failed' });
}
Maps to API8:2023 (security misconfiguration).
const cors = require('cors');
// Development: Allow localhost
const developmentOrigins = [
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1:3000'
];
// Production: Specific domains only
const productionOrigins = [
'https://yourapp.com',
'https://www.yourapp.com',
'https://app.yourapp.com'
];
const allowedOrigins = process.env.NODE_ENV === 'production'
? productionOrigins
: [...productionOrigins, ...developmentOrigins];
const corsOptions = {
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],
credentials: true, // Allow cookies
maxAge: 86400 // Cache preflight for 24 hours
};
app.use(cors(corsOptions));
// Handle CORS errors
app.use((err, req, res, next) => {
if (err.message === 'Not allowed by CORS') {
return res.status(403).json({ error: 'CORS not allowed' });
}
next(err);
});
// BAD: Allow all origins
app.use(cors()); // Defaults to '*'
// BAD: Allow all origins with credentials
app.use(cors({ origin: '*', credentials: true })); // Browsers will reject this
// BAD: Reflecting Origin header (allows any origin)
app.use(cors({
origin: (origin, cb) => cb(null, origin) // Never do this
}));
// BAD: Regex that's too permissive
const origin = /yourapp\.com/; // Matches evilyourapp.com too!
// GOOD: Exact match or strict regex
const origin = /^https:\/\/(www\.)?yourapp\.com$/;
Private Network Access (PNA) is a WICG draft (https://wicg.github.io/private-network-access/) that adds CORS-style preflight when a public origin tries to reach a private-network resource (LAN IPs, localhost, intranet hostnames). As of 2026-05-08 only Chromium enforces it, and even there enforcement has been gradually rolled back to warnings while the spec evolves. Treat it as a draft: don't rely on PNA as a primary defense, but do plan for the day it lights up everywhere — keep your private-network APIs behind real authentication, not just network position.
Maps to API1:2023 (broken object level authorization) when keys scope per-user data, and API2:2023 (broken authentication) for credential lifecycle.
const crypto = require('crypto');
// Generate API key
function generateApiKey() {
// Format: prefix_randomBytes
// Prefix helps identify key type and makes it recognizable
const prefix = 'sk_live';
const randomPart = crypto.randomBytes(24).toString('base64url');
return `${prefix}_${randomPart}`;
}
// Hash for storage (never store plain keys)
function hashApiKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
// Create new API key
app.post('/api-keys', requireAuth, async (req, res) => {
const { name } = req.body;
// Generate key
const plainKey = generateApiKey();
const keyHash = hashApiKey(plainKey);
// Store only the hash
await db.query(
`INSERT INTO api_keys (user_id, key_hash, name, created_at)
VALUES ($1, $2, $3, NOW())`,
[req.userId, keyHash, name]
);
// Return plain key ONCE - user must save it
res.json({
key: plainKey,
message: 'Save this key now. It will not be shown again.'
});
});
// Verify API key
async function verifyApiKey(key) {
const keyHash = hashApiKey(key);
const result = await db.query(
`SELECT id, user_id, revoked, last_used_at
FROM api_keys WHERE key_hash = $1`,
[keyHash]
);
if (result.rows.length === 0) {
return null;
}
const keyData = result.rows[0];
if (keyData.revoked) {
return null;
}
// Update last used timestamp
await db.query(
'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
[keyData.id]
);
return keyData;
}
// Revoke API key
app.delete('/api-keys/:id', requireAuth, async (req, res) => {
// Users can only revoke their own keys
await db.query(
'UPDATE api_keys SET revoked = true, revoked_at = NOW() WHERE id = $1 AND user_id = $2',
[req.params.id, req.userId]
);
res.json({ success: true });
});
Even without a compromise signal, rotate API keys on a schedule (90 days is a common baseline; tighten for high-privilege keys). Long-lived keys collect risk: leaked log lines, stale CI secrets, departed employees, forgotten test scripts. The rotation flow should support overlap — a "next" key live alongside the "current" one for the rotation window — so callers can swap without an outage.
// Add columns: rotated_from, rotation_due_at
// On rotation:
// 1. Generate new key
// 2. Insert with rotated_from = old key id, expires_at = old key expiry + window
// 3. Notify the key owner with the new value (out-of-band)
// 4. Mark old key revoked = true at the end of the overlap window
// Background job: flag keys past their rotation_due_at
const stale = await db.query(
`SELECT id, user_id, name FROM api_keys
WHERE revoked = false
AND rotation_due_at < NOW()`
);
// Email each owner; auto-revoke after a grace period.
async function apiKeyAuth(req, res, next) {
// Accept the key from headers ONLY — never query strings.
// Query strings are routinely logged by web servers, reverse proxies, CDN
// edge nodes, and analytics tooling, so a key in `?api_key=...` becomes a
// credential leak by way of access logs. OWASP API Top 10 (API2:2023) and
// RFC 6750 §2.3 both call this out.
const apiKey = req.headers['x-api-key']
|| req.headers['authorization']?.replace('Bearer ', '');
if (!apiKey) {
return res.status(401).json({
error: 'API key required',
hint: 'Pass API key in X-API-Key header'
});
}
const keyData = await verifyApiKey(apiKey);
if (!keyData) {
// Don't reveal if key exists but is revoked
return res.status(401).json({ error: 'Invalid API key' });
}
req.apiKeyId = keyData.id;
req.userId = keyData.user_id;
next();
}
Maps to API4:2023 (unrestricted resource consumption).
const express = require('express');
// Global body size limit
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ limit: '100kb', extended: true }));
// Per-route limits
app.post('/api/upload', express.json({ limit: '10mb' }), (req, res) => {
// Handle large upload
});
// File upload limits
const multer = require('multer');
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5 // Max 5 files
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
}
});
app.post('/upload', upload.single('file'), (req, res) => {
// Handle upload
});
Maps to API8:2023 (security misconfiguration).
// BAD: Leaking stack traces
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack // Never in production!
});
});
// GOOD: Generic error in production
app.use((err, req, res, next) => {
console.error(err); // Log full error server-side
if (process.env.NODE_ENV === 'production') {
res.status(500).json({ error: 'Internal server error' });
} else {
res.status(500).json({ error: err.message, stack: err.stack });
}
});
// BAD: Revealing database structure
res.status(400).json({
error: 'duplicate key value violates unique constraint "users_email_key"'
});
// GOOD: User-friendly error
res.status(400).json({
error: 'An account with this email already exists'
});
Maps to OWASP Top 10:2025 A05 (injection). The 2026 shape of XSS defense is, in priority order:
'strict-dynamic' — modern strict CSP. Allowlists (script-src https://cdn.foo.com …) are no longer recommended; per Weichselbaum et al. they're routinely bypassable. Nonce-or-hash plus 'strict-dynamic' is the W3C-blessed strict CSP.require-trusted-types-for 'script') — kills DOM-based XSS by forcing dangerous sinks (innerHTML, eval, setTimeout(string)) to consume policy-vetted objects. Cross-browser as of 2026 (Chromium since 2020, Firefox 148, Safari 26.0; ~89% global usage per https://caniuse.com/trusted-types).dangerouslySetInnerHTML, v-html, [innerHTML], {@html}) without DOMPurify.// BAD: Directly inserting user content
res.send(`<h1>Hello ${userName}</h1>`);
// GOOD: Use a template engine with auto-escaping
// EJS (auto-escapes by default with <%= %>)
res.render('greeting', { name: userName });
// GOOD: Escape manually when needed
const escapeHtml = require('escape-html');
res.send(`<h1>Hello ${escapeHtml(userName)}</h1>`);
// GOOD: Set Content-Type for JSON responses
res.json({ name: userName }); // Express sets correct headers
// In React/Vue/Angular/Svelte: framework handles escaping by default.
// Don't use dangerouslySetInnerHTML / v-html / [innerHTML] / {@html} on user input.
For the 'unsafe-inline' and inline-script story, see the security headers section — strict CSP3 makes the inline question moot when nonces are wired through the template.
Maps to API8:2023 (security misconfiguration). helmet is on the 8.x line as of 2026-05-08 (8.1.0 current; verify before pinning). The 2026 strict-CSP baseline replaces the older "set every X- header" advice.
const helmet = require('helmet');
const crypto = require('crypto');
// Per-request CSP nonce middleware (wire this into your template engine)
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
// Strict CSP3: nonce + 'strict-dynamic' + downgrade fallbacks
app.use(helmet.contentSecurityPolicy({
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
// Strict CSP per https://www.w3.org/TR/CSP3/ and MDN strict-CSP guide.
// 'strict-dynamic' lets nonced scripts load further scripts;
// https: + 'unsafe-inline' are downgrade fallbacks for old browsers
// that ignore 'strict-dynamic' (modern browsers ignore the fallbacks).
scriptSrc: [
(req, res) => `'nonce-${res.locals.cspNonce}'`,
"'strict-dynamic'",
'https:',
"'unsafe-inline'"
],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.yourapp.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"], // replaces X-Frame-Options
formAction: ["'self'"],
upgradeInsecureRequests: [],
// Trusted Types: kills DOM-based XSS at the sink.
// Cross-browser as of 2026 per https://caniuse.com/trusted-types
requireTrustedTypesFor: ["'script'"],
// Reporting via the Reporting API (W3C WD)
reportTo: ['default']
}
}));
// HSTS: 2 years + includeSubDomains + preload (per https://hstspreload.org)
// Operationally irreversible once preload-listed — verify subdomains first.
app.use(helmet.hsts({
maxAge: 63072000,
includeSubDomains: true,
preload: true
}));
// Cross-origin isolation (COOP / COEP / CORP)
// Required for crossOriginIsolated === true (SharedArrayBuffer, high-resolution timers).
// Per WHATWG HTML Origin chapter: https://html.spec.whatwg.org/multipage/origin.html
app.use((req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); // or 'credentialless'
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
next();
});
// Permissions-Policy: structured-fields syntax, opt-out of dangerous features.
// Per https://www.w3.org/TR/permissions-policy/ (W3C WD 2025-10-06; per-directive
// caniuse lookup required — this is "Limited Availability", not Baseline).
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()'
);
next();
});
// Reporting API: Reporting-Endpoints + report-to wiring.
// Per https://www.w3.org/TR/reporting-1/
app.use((req, res, next) => {
res.setHeader(
'Reporting-Endpoints',
'default="https://reports.yourapp.com/csp"'
);
next();
});
// Keep nosniff
app.use(helmet.noSniff());
// Note: X-XSS-Protection is deprecated and ignored by modern browsers — do not set it.
// X-Frame-Options is replaced by CSP's frame-ancestors directive (set above).
When you render HTML, the template needs to read res.locals.cspNonce and emit it on every inline <script> and <style> tag (<script nonce="<%= cspNonce %>">…). That's how the strict-dynamic policy decides which scripts to trust.
Maps to OWASP Top 10:2025 A03 (software supply chain failures). The Polyfill.io 2024 incident is the canonical reason: Funnull bought the polyfill.io domain in February 2024 and injected malware into ~110k sites that loaded the script (per https://sansec.io/research/polyfill-supply-chain-attack). A CSP 'strict-dynamic' policy would have blocked the unauthorized script-loaded-by-script chain; an SRI hash would have failed the load even if the policy permitted it.
Every third-party <script src> and <link rel="stylesheet"> SHOULD carry an integrity attribute. SRI is a W3C Recommendation (https://www.w3.org/TR/SRI/).
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
<link
rel="stylesheet"
href="https://cdn.example.com/lib.min.css"
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
crossorigin="anonymous">
Generate an SRI hash from a fetched asset:
curl -fsSL https://cdn.example.com/lib.min.js \
| openssl dgst -sha384 -binary \
| openssl base64 -A
cdnjs and jsdelivr include integrity attributes in their copy-paste snippets; unpkg has an "SRI-on" mode. Use them. Pin to a specific version, never @latest.
Maps to OWASP Top 10:2025 A04 (cryptographic failures) and API8:2023 (security misconfiguration).
If you operate behind a managed edge (Cloudflare, Fastly, AWS CloudFront, Azure Front Door), the right knob is usually a single "minimum TLS version" setting — set it to 1.2 at minimum, prefer 1.3, and let the edge handle the cipher suite negotiation. If you terminate TLS yourself, consult the Mozilla SSL Configuration Generator (https://ssl-config.mozilla.org/) for current "intermediate" or "modern" profiles.
Maps to API4:2023 (unrestricted resource consumption) and API7:2023 (server side request forgery — pair timeouts with outbound URL allowlists).
// Request timeout middleware
function timeout(ms) {
return (req, res, next) => {
res.setTimeout(ms, () => {
res.status(408).json({ error: 'Request timeout' });
});
next();
};
}
app.use(timeout(30000)); // 30 second default
// External API call timeout
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
// Database query timeout
const result = await db.query({
text: 'SELECT * FROM large_table WHERE condition = $1',
values: [value],
timeout: 5000 // 5 second query timeout
});
Library versions current as of 2026-05-08 (verify on PyPI before pinning):
fastapi is on the 0.x line (0.136.1 current; pre-1.0, so pin by minor — breaking changes can land between minors).slowapi is on the 0.1.x line (pre-1.0; pin by minor).pydantic is on the 2.x line (2.13.4 current).from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter
from slowapi.util import get_remote_address
from pydantic import BaseModel, EmailStr, Field
import hashlib
import secrets
app = FastAPI()
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourapp.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.post("/api/login")
@limiter.limit("5/minute")
async def login(request: Request, credentials: LoginRequest):
# Handle login
pass
# Input validation with Pydantic 2.x
class CreateUserRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=12, max_length=128)
name: str = Field(max_length=100, default=None)
@app.post("/users")
async def create_user(user: CreateUserRequest):
# Data is already validated
pass
# API key generation
def generate_api_key() -> str:
return f"sk_live_{secrets.token_urlsafe(24)}"
def hash_api_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
Organized by OWASP API Security Top 10:2023 category. Skip categories that don't apply (API5 / API9 are out of scope here).
secure-auth skill for the auth primitive itselfObject.assign, model.update(req.body)) avoided'strict-dynamic' + Trusted Typesmax-age=63072000; includeSubDomains; preloadX-Content-Type-Options: nosniffX-XSS-Protection NOT set (deprecated)X-Frame-Options replaced by CSP frame-ancestors* with credentials<script> and <link rel="stylesheet">development
Use this skill when creating new files that represent architectural decisions — data models, infrastructure configs, auth boundaries, API contracts, CI/CD pipelines, or event systems. Flags irreversible decisions and forces a discussion about trade-offs before committing.
testing
Configure install-time cooldowns for npm/bun (minimum release age) and run a sandboxed pre-install scan when the cooldown has to be bypassed. Use when the user asks about supply-chain attacks, npm/bun security, "minimum release age", a "cooldown" for installs, hardening against Shai-Hulud-class worms, or how to safely install a package that was just published. Also use after any recent supply-chain incident in the npm ecosystem.
tools
Generate CLAUDE.md project memory files that transfer institutional knowledge, not obvious information. Use when setting up new journalism projects, onboarding collaborators, or documenting project-specific quirks. Includes templates for editorial tools, event websites, publications, research projects, content pipelines, and digital archives.
development
Use when suggesting APIs for a project, looking for free data sources, building weekend projects that need external data, or when the user needs weather, news, finance, sports, ML, or entertainment data without paid subscriptions