API hardening
SkillCloud & infraAPI hardening for Express, FastAPI, and serverless. Use for rate limiting, CORS, input validation, API keys, or OWASP API Top 10.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the API hardening skill
What this skill tells your AI
The instructions your AI receives, as published by jamditis/claude-skills-journalism in security-toolkit/skills/api-hardening/SKILL.md and read by ahel’s review.
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.
Step 0: Research the current security landscape (do this first)
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.
Default-on, with a documented skip
Run the 4-angle research below by default. Skip ONLY when ALL of these hold:
- (a) You ran this same skill on this same primitive within the last 4 hours of the current session,
- (b) That prior research surfaced no urgent advisories for the API surface or web defense being added,
- (c) You log a one-line
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.
Fan out 4 subagents in parallel
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.
Synthesize before applying recipes
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:
- The current normative ceiling (what specs say SHOULD be the default in 2026).
- 1-2 active threats specific to the API surface or web defense being added from the last 6-12 months.
- Any tooling drift (deprecated lib, new default in a framework, package merged or replaced).
- Any practitioner consensus shift visible in recent cheat sheet / blog updates.
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.
When you cannot run subagents
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.
OWASP API Security Top 10:2023 mapping
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:
- API1:2023 Broken object level authorization, enforce per-object access checks; covered alongside API key management and per-user rate limiting.
- API2:2023 Broken authentication, see the
secure-authskill for the auth primitive itself; this skill covers rate limits, request size, and timeout protections that flank auth endpoints. - API3:2023 Broken object property level authorization, input validation (Zod / Pydantic) and explicit allowlist of writable fields.
- API4:2023 Unrestricted resource consumption, rate limiting, request size limits, timeout protection, file upload limits.
- API5:2023 Broken function level authorization, out of scope here; route-level auth lives in your framework.
- API6:2023 Unrestricted access to sensitive business flows, graph-traversal quotas, per-account read budgets, behavioral signals for credential stuffing.
- API7:2023 Server side request forgery, outbound URL allowlists; covered briefly under timeout protection and external API calls.
- API8:2023 Security misconfiguration, security headers, TLS posture, CORS configuration.
- API9:2023 Improper inventory management, out of scope here; an API gateway / catalog problem.
- API10:2023 Unsafe consumption of APIs, input validation on data fetched from upstream, plus deserialization safety.
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.
Threat exemplars
Real 2023-2024 incidents that anchor the patterns in this skill. Cite these when explaining "why" to stakeholders.
- Polyfill.io supply-chain attack (2024-06-25). Funnull acquired the polyfill.io domain in February 2024 and injected malware into roughly 110,000 sites that loaded the script (per https://sansec.io/research/polyfill-supply-chain-attack). Lesson: every third-party
<script>and<link rel="stylesheet">needs a Subresource Integrity hash and a strict CSP; "trusted CDN" is not a guarantee. - 23andMe credential stuffing (disclosed 2023-10). Attackers reused leaked credentials against 23andMe accounts, then pivoted via the DNA Relatives feature to enumerate roughly 6.9 million users from a smaller initial-account compromise (per https://blog.23andme.com/articles/addressing-data-security-concerns). Lesson: per-IP rate limits don't catch distributed credential stuffing, and per-account read quotas on graph or relationship endpoints are needed to cap the blast radius.
- MOVEit Transfer CVE-2023-34362 (disclosed 2023-05-31). A pre-authentication SQL injection in Progress MOVEit Transfer (per https://nvd.nist.gov/vuln/detail/CVE-2023-34362) was used by Cl0p to exfiltrate data from thousands of organizations, and the chain landed in the CISA KEV catalog. Lesson: parameterized queries are not optional, even on file-transfer paths that don't look like "user-facing APIs."
- Ivanti Connect Secure zero-day chain (disclosed 2024-01). CVE-2023-46805 (auth bypass) and CVE-2024-21887 (command injection) chained for unauthenticated remote code execution (per https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-060b). Lesson: command injection (CWE-78) plus auth bypass is the modern unauthenticated-RCE recipe; both halves need defenses.
Rate limiting
Maps to API4:2023 (unrestricted resource consumption) and API6:2023 (sensitive business flows).
Why it matters
Without rate limiting:
- Brute force attacks succeed
- APIs get DDoS'd by accident or intent
- One bad actor affects all users
- You get a surprise bill from your cloud provider
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.
Express.js with express-rate-limit
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);
Sliding window implementation (custom)
// 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();
};
}
Per-user rate limiting with API keys
// 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();
}
Per-account graph-traversal quota (23andMe lesson)
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);
Input validation
Maps to API3:2023 (broken object property level authorization) and API10:2023 (unsafe consumption of APIs).
Validation with Zod (TypeScript/JavaScript)
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
});
Sanitization
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);
}
Preventing SQL injection
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
Command injection (CWE-78)
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.
Node.js
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 });
Python
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")
Deserialization (CWE-502)
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.
Python
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
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 393
- Forks
- 65
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
api-hardening- Source
- github.com/jamditis/claude-skills-journalism