OWASP A04 Insecure Design Guide
OWASP A04:2021 Insecure Design isn't about buggy code — it's about missing threat modeling and business logic flaws. Learn to spot and prevent it with real examples.
Rod
Founder & Developer
OWASP A04:2021 Insecure Design is the only category in the OWASP Top 10 where patching the code won't fix the problem. The design itself is the vulnerability. It entered the OWASP Top 10 for the first time in 2021 — not because it's new, but because the industry finally acknowledged it as distinct from implementation bugs. Understanding the difference changes how you think about security from the start.
This guide walks through what insecure design actually looks like in practice, with code showing the vulnerable patterns and the secure alternatives. No abstract theory — just real scenarios you've probably shipped or reviewed.
Design Flaws vs. Implementation Bugs — Why the Distinction Matters
Here's a concrete example. You build a login system. You hash passwords with bcrypt. You use parameterized queries. You validate inputs. The implementation is clean.
But you don't add rate limiting on the password reset endpoint.
An attacker sends 10,000 reset requests to the same email address in one minute. Your email provider bills you for 10,000 sends. Your user's inbox floods. Your server slows down. None of this is a bug in your code — every individual request is handled correctly. The design didn't account for how the system would be abused.
That's the core of insecure design: the system works as designed, and that's the problem.
Implementation bugs — SQL injection, XSS, hardcoded secrets — are dangerous because a line of code does something unintended. Insecure design flaws are dangerous because the system does exactly what it was built to do, just without accounting for adversarial use.
The practical consequence: you can't patch your way out of insecure design. You have to change the architecture.
Insecure design is what happens when you build the feature before asking "how would someone abuse this?"
Common Insecure Design Patterns (With Real Code)
No Rate Limiting on Sensitive Flows
Password reset. OTP verification. Login attempts. These endpoints exist to recover or verify access — and they're exactly the flows attackers target with brute force and enumeration attacks.
The pattern: An endpoint that accepts a credential (OTP, reset token, password) and checks it against a stored value, with no limit on how many times you can try.
// BAD: No rate limiting — an attacker can try every 6-digit OTP (1,000,000 combinations)
// in minutes with parallel requests
export async function POST(req: Request) {
const { phone, otp } = await req.json();
const stored = await db.otpCodes.findOne({ phone });
if (stored.code === otp) {
return Response.json({ success: true, token: generateToken(phone) });
}
return Response.json({ error: "Invalid code" }, { status: 400 });
}// GOOD: Rate limit by IP and by phone number, expire codes, limit attempts
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
// Check rate limit — 5 attempts per phone per 10 minutes
const attempts = await rateLimit.check(`otp:${phone}`, { limit: 5, window: 600 });
if (!attempts.ok) {
return Response.json({ error: "Too many attempts" }, { status: 429 });
}
const stored = await db.otpCodes.findOne({ phone, expiresAt: { gt: new Date() } });
if (!stored || stored.code !== otp || stored.attempts >= 5) {
await db.otpCodes.increment({ phone }, "attempts");
return Response.json({ error: "Invalid or expired code" }, { status: 400 });
}
await db.otpCodes.delete({ phone });
return Response.json({ success: true, token: generateToken(phone) });
}Real-world consequence: In 2016, researchers demonstrated that Uber's driver OTP verification had no rate limiting. An attacker could enumerate OTP codes by brute force, take over driver accounts, and access passenger data. The flaw wasn't in the OTP generation or storage — it was in the design that didn't limit attempts.
Client-Side Price Calculation
This one shows up constantly in e-commerce, SaaS checkout flows, and any app where users select quantities or apply discounts. The pattern is seductive because it makes the frontend feel fast and responsive.
// BAD: Price calculated on the client, total sent to the server
// Any user can open DevTools and change item prices before submitting
const checkout = async () => {
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ items, total }), // Don't trust this total
});
};// GOOD: Server receives item IDs and quantities only
// Prices are fetched from the database — never from the request
export async function POST(req: Request) {
const { items } = await req.json(); // Only IDs and quantities
// Fetch current prices from DB — the request price values are ignored
const products = await db.products.findMany({
where: { id: { in: items.map((i) => i.id) } },
});
const total = items.reduce((sum, item) => {
const product = products.find((p) => p.id === item.id);
if (!product) throw new Error(`Unknown product: ${item.id}`);
return sum + product.price * item.qty; // DB price, not client price
}, 0);
return stripe.paymentIntents.create({ amount: total, currency: "usd" });
}The secure version ignores any price value the client sends. It uses item IDs to look up current prices in the database, then computes the total server-side. This is the only correct approach — there's no "secure client-side price" because the client is controlled by the user.
Race Conditions in Payment and Inventory Flows
Race conditions in payments are a classic insecure design pattern. The design assumes requests arrive one at a time. In reality, a user (or script) can fire multiple simultaneous requests.
// BAD: Check-then-act without locking — two simultaneous requests
// both pass the balance check before either deduction commits
export async function POST(req: Request) {
const user = await db.users.findOne({ id: userId });
if (user.credits < itemCost) {
return Response.json({ error: "Insufficient credits" }, { status: 402 });
}
// A second request can pass the check above before this line runs
await db.users.update({ id: userId }, { credits: user.credits - itemCost });
await fulfillOrder(userId, itemId);
}// GOOD: Atomic decrement that fails if the result would go below zero
// Database enforces the constraint — no race condition possible
export async function POST(req: Request) {
const result = await db.$executeRaw`
UPDATE users
SET credits = credits - ${itemCost}
WHERE id = ${userId} AND credits >= ${itemCost}
RETURNING credits
`;
if (result.rowCount === 0) {
return Response.json({ error: "Insufficient credits" }, { status: 402 });
}
await fulfillOrder(userId, itemId);
}The fix pushes the constraint down to the database, where it can be enforced atomically. No amount of concurrent requests can bypass a properly written atomic SQL operation. You can read more about race conditions in payment flows and what they cost real businesses.
GraphQL Without Query Depth Limiting
REST APIs have natural protection against query abuse — each endpoint returns a fixed shape of data. GraphQL's flexibility is also its attack surface. Without a query depth limit, a single request can trigger exponentially nested database queries.
# BAD: A legitimate-looking query that causes exponential DB load
# friends -> friends -> friends -> ... 10 levels deep = thousands of DB queries
{
user(id: "1") {
friends {
friends {
friends {
friends {
name
email
}
}
}
}
}
}// GOOD: Enforce query depth limit at the GraphQL layer
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(5), // Reject queries nested more than 5 levels
],
});A depth limit of 5 is reasonable for most APIs. Set it too low and legitimate queries fail. Set it too high (or skip it) and you're one curl command away from a self-inflicted DoS. Learn more about GraphQL query depth limit attacks and how they work at scale.
Missing Pagination — The Data Dump
An endpoint that returns all records without pagination is one missed LIMIT clause away from handing a complete database export to anyone who calls it. This is an insecure design pattern because the decision to paginate (or not) is architectural, not a bug in a single query.
// BAD: Returns every record — one request dumps the entire table
export async function GET(req: Request) {
const users = await db.users.findMany(); // No limit, no offset
return Response.json(users);
}// GOOD: Enforce pagination — cap the maximum page size
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "20")); // Cap at 100
const [items, total] = await Promise.all([
db.users.findMany({ skip: (page - 1) * limit, take: limit }),
db.users.count(),
]);
return Response.json({ items, total, page, limit });
}Parler's 2021 data breach is the textbook example. Their API had no authentication on user data endpoints and returned records via sequential numeric IDs. With no rate limiting and no auth, anyone could enumerate every user, post, and deleted post by incrementing a number. The code worked exactly as designed. The design was the problem.
Learn more about how missing pagination enables data dumps in practice.
File Upload Without Validation
Allowing file uploads without validating type, size, and content is a design gap that shows up in insecure design guides for good reason. Checking the file extension alone isn't enough — MIME types can be spoofed in the request headers. You need to validate the actual file contents.
// BAD: Checks Content-Type header (user-controlled) but not actual file content
// An attacker sends a PHP shell with Content-Type: image/jpeg
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file.type.startsWith("image/")) {
return Response.json({ error: "Images only" }, { status: 400 });
}
await storage.upload(file); // Stores whatever was actually sent
}// GOOD: Validate file extension, MIME type, file size, AND magic bytes
import { fileTypeFromBuffer } from "file-type";
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("file") as File;
// 1. Size check first (fast, cheap)
if (file.size > 5 * 1024 * 1024) {
return Response.json({ error: "File too large (max 5MB)" }, { status: 400 });
}
// 2. Check magic bytes — the actual file contents, not the header
const buffer = Buffer.from(await file.arrayBuffer());
const detected = await fileTypeFromBuffer(buffer);
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!detected || !allowedTypes.includes(detected.mime)) {
return Response.json({ error: "Invalid file type" }, { status: 400 });
}
// 3. Generate a random filename — never trust the original filename
const filename = `${crypto.randomUUID()}.${detected.ext}`;
await storage.upload(buffer, filename);
}The key difference: magic bytes are the first few bytes of the actual file content that identify the format. A JPEG starts with FF D8 FF. A PNG starts with 89 50 4E 47. These bytes can't be easily faked because they're part of the actual file structure. Read more about file upload validation and what happens when you skip it.
How to Actually Prevent Insecure Design
Insecure design can't be caught by a linter. It can't be fixed by a security patch. It has to be addressed before you write the first line of code for a feature. Here's what that looks like in practice.
Threat Modeling — Before You Code
For every significant feature, ask these questions before implementation:
- Who might abuse this, and how? — Not just external attackers. Malicious users, scrapers, your own employees.
- What's the worst-case scenario if this breaks? — Financial loss, data exposure, service disruption?
- What assumptions is this design making? — "Users will only submit one request at a time." "The client won't modify the price." These assumptions are your attack surface.
- Where are the trust boundaries? — What data crosses from untrusted (client) to trusted (server)? Every crossing needs validation.
OWASP's Threat Modeling Cheat Sheet is a practical starting point if you've never done this formally before.
Abuse Cases Alongside Use Cases
When you write "User can reset their password," also write "Attacker can spam the reset endpoint to harass a user" and "Attacker can enumerate which emails have accounts by timing the response." These are your abuse cases. Each one implies a design requirement: rate limiting, constant-time responses, lockout policies.
OWASP's ASVS (Application Security Verification Standard) provides a structured set of security requirements organized by feature area. It's worth at least skimming the sections relevant to your app.
Server-Side Validation for All Business Logic
This is the single most impactful rule: never trust the client for any business-critical calculation. Price totals, discount calculations, inventory counts, permission checks — all of it must be computed server-side from data you control.
Client-side validation is fine for UX. It is not security. Anything that only exists in the browser can be modified by the person using the browser.
Defense in Depth
Design each layer as if the layer above it has already failed. Your database should enforce constraints even if your API layer doesn't check them. Your API should validate input even if your frontend already validated it. Your payment processor should have fraud rules even if your backend already checked the order.
A single missing check rarely causes a breach. But "we only checked it in the frontend" is a phrase that shows up in post-mortems more than anyone wants to admit.
Scan Your App for Insecure Design Patterns
Static analysis tools are better at catching implementation bugs than design flaws. But some insecure design patterns do leave traces in code — missing rate limiting middleware, file upload handlers that skip magic byte checks, GraphQL schemas without depth rules.
When we scan repos at Data Hogo, missing rate limiting on auth endpoints is one of the most frequent findings — not because it's hard to add, but because it's easy to forget when you're moving fast. It shows up as a High severity finding because the attack surface is obvious and the fix takes about 10 minutes.
Scan your repo free → and see which design-level findings come up in your project. The scan takes under 60 seconds and doesn't require a credit card.
You can also read the specific encyclopedia entries for the patterns covered in this post:
- Insecure Design — overview and examples
- Race Condition in Payments
- Price Manipulation via Client-Side Calculation
- GraphQL Without Query Depth Limit
- Missing Pagination — data dump risk
- File Upload Without Validation
Frequently Asked Questions
What is OWASP A04:2021 Insecure Design?
OWASP A04:2021 Insecure Design covers security failures that stem from missing or inadequate design decisions — not coding bugs. It includes missing threat modeling, no security requirements, business logic flaws, and architectural gaps like missing rate limiting. The difference from other OWASP categories is that you can't fix these with a code patch alone; the design itself needs to change.
What is the difference between insecure design and insecure implementation?
Insecure implementation is when correct design is executed with a bug — like a SQL injection in an otherwise well-designed query layer. Insecure design is when the design itself is wrong — like calculating order totals on the client side. Even perfect, bug-free code can implement an insecure design. Both need fixing, but insecure design requires architectural changes, not just code patches.
How do I prevent client-side price manipulation?
Never trust prices sent from the client. Always recalculate the total server-side using prices fetched directly from your database. Your API should receive item IDs and quantities, look up the current price for each item in the database, compute the total, and then charge that amount. Any price value in the request body should be ignored.
What is a race condition in payments and how do I prevent it?
A payment race condition occurs when a user sends multiple simultaneous requests to an endpoint that checks a balance or stock count before deducting it. Both requests pass the check at the same time, before either deduction has committed. Prevention requires database-level atomic operations (UPDATE ... WHERE credits >= cost), idempotency keys on payment intents, or pessimistic locking (SELECT FOR UPDATE) to serialize access.
Can static analysis tools catch insecure design flaws?
Not reliably. Most static analysis tools are great at finding implementation bugs — hardcoded secrets, SQL injection, XSS. But insecure design flaws live at the architectural level, not in individual lines of code. Tools like Data Hogo can flag patterns that indicate design problems (missing rate limiting on auth endpoints, file uploads without validation) but full threat modeling and abuse case review requires human judgment applied before the first commit.
Related Posts
OWASP A07 Authentication Failures Guide
OWASP A07 authentication failures with real code examples: weak passwords, JWT without expiry, localStorage tokens, no rate limiting, and how to fix each.
OWASP A01 Broken Access Control Guide
Broken access control is the #1 OWASP risk. This guide explains IDOR, missing auth checks, JWT tampering, and how to fix them with real Next.js code examples.
OWASP A02 Cryptographic Failures Guide
OWASP A02:2021 Cryptographic Failures is the #2 web vulnerability. Learn how plaintext passwords, weak hashing, and hardcoded keys expose your users — with real code examples.