What you’ll learn
- Identify the middleware security gaps AI coding agents consistently introduce
- Map each middleware layer to the OWASP API Security Top 10 risks it defends against
- Build JWT authentication middleware with RS256 validation and refresh token rotation
- Configure rate limiting, input sanitization, CORS, and security headers as a layered stack
- Audit your own middleware using Claude Code as a security reviewer
Prerequisites
- Claude Code installed and configured
- Node.js 20+ and an Express.js project (the examples use Express 5, but Express 4 works too)
- Familiarity with JWT concepts (tokens, claims, signing algorithms)
- Understanding of OWASP API Security Top 10 categories
- A security mindset — you should know why broken access control matters, not just what it is
The middleware security gap
In March 2026, DryRun Security published The Agentic Coding Security Report, in which Claude Code (running Sonnet 4.6), OpenAI Codex, and Google Gemini each built two applications through a series of pull requests. Help Net Security’s write-up covers the findings. One pattern matters for anyone building API middleware with AI assistance.
Every agent defined rate limiting middleware in its codebase. None of them wired it into the application. The code existed; the protection did not.
JWT secret management was weak too: all three agents left hardcoded fallback secrets in the game app, which lets an attacker forge valid tokens without stealing credentials. In the web app, Claude Code finished with 13 issues, including a 2FA-disable bypass the other agents didn’t introduce. It’s one vendor’s study on two apps, but the failure modes match what we see when we review agent-built services.
AI coding agents build middleware that looks correct in code review but fails at the architectural level. Rate limiters that never execute, authentication that covers REST routes but not the WebSocket upgrade, and hardcoded fallback secrets were all findings in that report.
The fix is not to avoid Claude Code for security work. The fix is to prompt with explicit security constraints and verify that every layer is actually mounted. This article walks you through both.
Map your middleware to OWASP
Before writing code, map each middleware layer to the OWASP API Security Top 10 (2023) risk it defends against. This prevents the “dead middleware” problem — every layer has a specific threat it addresses, and you can check that each one is live.
| Middleware Layer | OWASP Risk | What it prevents |
|---|---|---|
| JWT authentication | API2: Broken Authentication | Forged tokens, expired sessions, algorithm confusion |
| Authorization checks | API1: Broken Object Level Authorization | Users accessing resources they do not own |
| Rate limiting | API4: Unrestricted Resource Consumption | Brute force, request floods, credential stuffing |
| Input validation | API3: Broken Object Property Level Authorization | Mass assignment of properties the client should not set |
| CORS | API8: Security Misconfiguration | Cross-origin requests from unauthorized domains |
| Security headers | API8: Security Misconfiguration | Clickjacking, MIME sniffing, missing HSTS |
Input validation also blunts injection attacks such as XSS and SQL injection. The 2023 API list doesn’t rank injection as its own category, so we don’t force it into one here.
The middleware execution order matters. CORS and security headers run first because they affect the HTTP response regardless of authentication status. Rate limiting runs before authentication to prevent brute force on login endpoints. Input validation runs before route handlers so no unvalidated data reaches your business logic.
Build JWT authentication middleware
Start by asking Claude Code to generate the JWT middleware with explicit security requirements. The prompt specificity is what separates secure output from vulnerable output.
Give Claude Code this prompt in your project:
Build JWT authentication middleware for this Express API. Requirements:
- Verify tokens using RS256 algorithm only — reject any token using HS256 or none
- Validate exp, aud, and iss claims against environment variables
- Attach decoded payload to req.auth
- Return 401 with a generic JSON error body — never include token details or validation reasons in the response
- Create a separate refresh token endpoint using HttpOnly Secure SameSite=Strict cookies
- The refresh endpoint must rotate tokens on every use and invalidate the old refresh token
Never accept the output of a prompt like “add JWT auth to my API” without checking the signing algorithm. Agents commonly default to HS256 with a string secret, and a weak or hardcoded secret can be brute-forced or leaked. We use RS256 with asymmetric keys and a JWKS endpoint in production.
The generated middleware should look similar to this:
import type { Request, Response, NextFunction } from "express";
import { expressjwt, type GetVerificationKey } from "express-jwt";
import jwksRsa from "jwks-rsa";
const jwtCheck = expressjwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: process.env.JWKS_URI!,
}) as GetVerificationKey,
audience: process.env.JWT_AUDIENCE,
issuer: process.env.JWT_ISSUER,
algorithms: ["RS256"],
});
function authErrorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err.name === "UnauthorizedError") {
res.status(401).json({ error: "Authentication required" });
return;
}
next(err);
}
export { jwtCheck, authErrorHandler };
express-jwt attaches the decoded payload to req.auth by default, which is why the prompt asks for it there. Three things to verify after generation:
- Algorithm pinning — the
algorithmsarray must contain only["RS256"], never["RS256", "HS256"] - Error handler — the response body must not include
err.message, which leaks validation details - JWKS caching —
cache: trueandrateLimit: truestop a flood of tokens with unknown key IDs from hammering your JWKS endpoint
Refresh token rotation
The refresh token flow needs its own security treatment. Ask Claude Code to generate it separately:
Build a refresh token endpoint at POST /auth/refresh. Requirements:
- Read refresh token from HttpOnly cookie, not from request body
- On valid refresh, issue new access + refresh tokens
- Invalidate the old refresh token in the database immediately
- If an already-used refresh token is presented, revoke ALL tokens for that user (reuse detection)
- Set cookie flags: HttpOnly, Secure, SameSite=Strict, Path=/auth/refresh
Reuse detection is the critical piece. If an attacker steals a refresh token and the legitimate user also uses it, one of them will present an already-consumed token. Revoking all tokens for that user forces re-authentication and stops the attacker.
Add rate limiting
Rate limiting is where AI agents most consistently fail — they generate the middleware but do not mount it. Your prompt must specify both the limits and the mount points.
Add rate limiting to this Express app:
- Global: 100 requests per 15 minutes per IP
- Auth endpoints (/auth/*): 5 requests per minute per IP
- Mount the global limiter on app.use() BEFORE auth middleware
- Mount the auth limiter on app.use("/auth", authLimiter) BEFORE auth routes
- Return standard RateLimit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset)
- Use a Redis store via rate-limit-redis for distributed deployments
- 01
Install dependencies
npm install express-rate-limit rate-limit-redis ioredis - 02
Create the rate limiter configuration
import { rateLimit } from "express-rate-limit"; import { RedisStore, type RedisReply } from "rate-limit-redis"; import Redis from "ioredis"; const redisClient = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379"); const sendCommand = (command: string, ...args: string[]) => redisClient.call(command, ...args) as Promise<RedisReply>; export const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 100, standardHeaders: "draft-6", legacyHeaders: false, store: new RedisStore({ sendCommand, prefix: "rl:global:" }), message: { error: "Too many requests, try again later" }, }); export const authLimiter = rateLimit({ windowMs: 60 * 1000, limit: 5, standardHeaders: "draft-6", legacyHeaders: false, store: new RedisStore({ sendCommand, prefix: "rl:auth:" }), message: { error: "Too many auth attempts, try again later" }, });Each limiter gets its own store with its own key prefix. If both used the same prefix, they would share one counter per IP and the auth limit would be counting global traffic.
standardHeaders: "draft-6"emits the separateRateLimit-Limit,RateLimit-Remaining, andRateLimit-Resetheaders the prompt asked for. - 03
Mount limiters in the correct order
import { globalLimiter, authLimiter } from "./middleware/rate-limit"; import { authRoutes } from "./routes/auth"; // ...after `const app = express();` // Rate limiting BEFORE authentication app.use(globalLimiter); app.use("/auth", authLimiter, authRoutes);Mounting order is the fix for the “dead middleware” problem. The global limiter goes on
app.use()before any route definitions. The auth limiter goes directly on the auth route prefix.
After generating rate limiting middleware, ask Claude Code: “Show me every place globalLimiter and authLimiter are referenced in the codebase.” If they only appear in their definition file and nowhere else, they are dead code. This single check catches the failure the DryRun report found in every codebase.
Input sanitization layer
Input validation defends against malformed and malicious data at the request boundary. We use zod for schema validation — it provides both type safety and runtime validation in a single declaration.
Ask Claude Code to generate validation middleware:
Create input validation middleware using zod for this Express 5 API.
Requirements:
- Validate request body, query params, and URL params separately
- Strip unknown fields from request body (prevent mass assignment)
- Sanitize string fields against XSS by stripping HTML tags
- Return 400 with field-level error messages on validation failure
- Store parsed values on res.locals.validated (req.query is read-only in Express 5)
- Create reusable schemas for common patterns: email, UUID, pagination
The generated middleware should follow this pattern:
import { z, type ZodSchema } from "zod";
import type { Request, Response, NextFunction } from "express";
import xss from "xss";
function sanitizeString(value: string): string {
return xss(value, { whiteList: {}, stripIgnoreTag: true });
}
// Use in schemas for free-text fields that may be rendered later
export const sanitizedString = z.string().transform(sanitizeString);
type Validated = { body?: unknown; query?: unknown; params?: unknown };
export function validate(schema: {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}) {
return (req: Request, res: Response, next: NextFunction) => {
const errors: Record<string, string[]> = {};
const validated: Validated = {};
for (const part of ["body", "query", "params"] as const) {
const partSchema = schema[part];
if (!partSchema) continue;
const result = partSchema.safeParse(req[part]);
if (!result.success) {
errors[part] = result.error.issues.map((i) => i.message);
} else {
validated[part] = result.data;
}
}
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: "Validation failed", details: errors });
return;
}
// Express 5 exposes req.query through a getter, so assigning to it
// has no effect. Handlers read parsed input from res.locals instead.
res.locals.validated = validated;
next();
};
}
Apply it to routes with explicit schemas, and read the parsed values in the handler:
import { Router, type Request, type Response } from "express";
import { z } from "zod";
import { validate, sanitizedString } from "../middleware/validate";
export const router = Router();
const createUserSchema = {
body: z.object({
email: z.string().email().max(255),
name: sanitizedString.pipe(z.string().min(1).max(100)),
role: z.enum(["viewer", "editor"]),
}).strict(),
};
router.post("/users", validate(createUserSchema), (req: Request, res: Response) => {
const { email, name, role } = res.locals.validated.body;
// ...create the user with the validated fields only
res.status(201).json({ email, name, role });
});
The .strict() modifier rejects any fields not defined in the schema. That blocks mass assignment, where an attacker slips a field like isAdmin or ownerId in alongside legitimate ones. Notice the role enum leaves out admin: a schema defines which values are well-formed, not who is allowed to set them. Granting elevated roles belongs behind an authorization check, not in a public create endpoint.
CORS and security headers
In our reviews, CORS is one of the settings agents most often get wrong, usually by reaching for a wildcard to make a local error go away. The fix is straightforward but requires discipline: never use wildcards in production.
Configure CORS for this Express API:
- Load allowed origins from ALLOWED_ORIGINS environment variable (comma-separated)
- Allow credentials (cookies) for the refresh token flow
- Restrict methods to GET, POST, PUT, DELETE, OPTIONS
- Set preflight cache to 24 hours
- Register CORS middleware BEFORE all routes
import cors from "cors";
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
export const corsMiddleware = cors({
origin: (origin, callback) => {
// Requests without an Origin header (curl, server-to-server) are not
// subject to CORS; browsers from unlisted origins get no CORS headers.
callback(null, !origin || allowedOrigins.includes(origin));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
maxAge: 86400,
});
Returning false for an unlisted origin makes the browser block the response without turning every rejected preflight into a 500 from your error handler. Remember that CORS only constrains browsers. It is not access control, which is why authentication and authorization still sit behind it.
For security headers, use helmet with an API-specific configuration:
import helmet from "helmet";
export const securityHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true },
referrerPolicy: { policy: "no-referrer" },
});
APIs that only return JSON should set defaultSrc: ["'none'"] in their CSP. This is stricter than the default helmet configuration, which allows 'self'. Since your API never serves HTML pages, there is no legitimate reason for a browser to load resources from it.
Wire it all together
The middleware execution order determines whether your stack actually protects the application. Here is the order we use, with the OWASP risk each layer addresses:
import express from "express";
import { corsMiddleware } from "./middleware/cors";
import { securityHeaders } from "./middleware/security-headers";
import { globalLimiter, authLimiter } from "./middleware/rate-limit";
import { jwtCheck, authErrorHandler } from "./middleware/auth";
import { authRoutes } from "./routes/auth";
import { apiRoutes } from "./routes/api";
const app = express();
// Layer 1: CORS (OWASP API8)
app.use(corsMiddleware);
// Layer 2: Security headers (OWASP API8)
app.use(securityHeaders);
// Layer 3: Rate limiting (OWASP API4)
app.use(globalLimiter);
// Layer 4: Body parsing
app.use(express.json({ limit: "10kb" }));
// Layer 5: Public routes with stricter rate limit
app.use("/auth", authLimiter, authRoutes);
// Layer 6: JWT authentication (OWASP API2)
app.use(jwtCheck);
app.use(authErrorHandler);
// Layer 7: Protected routes (validation per-route, OWASP API3)
app.use("/api", apiRoutes);
export { app };
Notice the express.json({ limit: "10kb" }) call. Setting a body size limit reduces exposure to oversized-payload attacks. The default Express limit is 100kb, which is more than most JSON APIs need. If your app also accepts WebSocket connections, authenticate the upgrade request separately; app.use(jwtCheck) does not see it, and the DryRun report found that gap in every final game codebase.
Bake it into CLAUDE.md
Add security rules to your project’s CLAUDE.md so every Claude Code session starts with them. This reduces the drift that happens when you prompt for security in one session but forget in the next.
## API Middleware Standards
- Every route MUST pass through auth middleware unless listed in PUBLIC_ROUTES
- Rate limiting: 100 req/15min global, 5 req/min on /auth/* endpoints
- CORS: load ALLOWED_ORIGINS from env, never use wildcard
- All user input MUST be validated with zod schemas before handlers
- JWT: RS256 only, verify exp/aud/iss claims, reject HS256 downgrade
- Security headers: helmet() with CSP defaultSrc 'none' for API
- Never log JWT tokens, passwords, or API keys
- Body parser limit: 10kb max
Claude Code reads CLAUDE.md at the start of every session, so these rules are in context for every file it generates. That turns a one-time prompt into a standing policy, but it is still guidance to the model, not enforcement — which is why the audit step below exists.
Audit your own stack
After building the middleware stack, use Claude Code as a security reviewer. This catches the gaps that your own prompts may have introduced.
Review the middleware stack in src/middleware/ for OWASP API Security
Top 10 violations. Check specifically for:
1. Are all destructive endpoints (DELETE, PUT) behind auth middleware?
2. Is rate limiting actually mounted on app.use(), not just defined?
3. Does CORS allow wildcard origins anywhere?
4. Are JWT tokens validated for exp, aud, and iss claims?
5. Are request bodies validated with schemas before reaching handlers?
6. Are security headers set via helmet?
7. Is the body parser size limited?
8. Do protected routes check that the caller owns the resource, not just that they are logged in?
List each finding with severity (critical/high/medium/low) and file path.
The finding we most often expect from a review like this is the one in item 8: authentication is present but authorization is missing. The JWT middleware confirms who the user is, but nothing checks whether that user is allowed to delete this specific resource. That’s OWASP API1, and no amount of middleware ordering fixes it. Add authorization checks that validate resource ownership in each handler or in a per-route middleware.
For automated coverage beyond Claude Code’s review, run your API through a dynamic application security testing (DAST) tool in CI, so a regression that unmounts a layer fails the build instead of shipping.
The DryRun findings line up with what we see in production reviews: the individual middleware files are usually fine, and the vulnerability lives in how they are wired together. That’s why our Build engagements treat “is every layer actually mounted, and is there a test that proves it” as a release gate, not a code review nicety.
Next steps
- Review AI-generated code in layers — a review process that catches wiring gaps like dead middleware
- OWASP API Security Top 10 — the full specification your middleware stack should defend against