Insecure Design is a new category in the 2021 OWASP Top 10, focusing on risks related to design and architectural flaws. This is different from implementation bugs—insecure design means that even a perfect implementation of a flawed design is still insecure.
What is Insecure Design?
Insecure design occurs when security requirements and controls are not considered during the design phase. It's the difference between:
- Insecure Implementation - A flaw in how you built something (e.g., SQL injection from string concatenation)
- Insecure Design - A flaw in what you decided to build (e.g., no rate limiting on password attempts)
You can't fix insecure design with a perfect implementation. If you design a house with no locks on the doors, installing the doors perfectly doesn't make the house secure.
Common Insecure Design Patterns
1. Missing Rate Limiting
The Problem:
Without rate limiting, attackers can make unlimited attempts at guessing passwords, brute-forcing tokens, or overwhelming your system.
// INSECURE DESIGN: No rate limiting on login
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ username });
if (user && await bcrypt.compare(password, user.password)) {
return res.json({ token: generateToken(user) });
}
res.status(401).json({ error: 'Invalid credentials' });
});
An attacker can try millions of password combinations with no restrictions.
Secure Design:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
// Global rate limiter for all requests
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, please try again later'
});
// Strict limiter for authentication endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
skipSuccessfulRequests: true,
message: 'Too many login attempts, please try again later',
// Use Redis for distributed rate limiting across multiple servers
store: new RedisStore({ client: redisClient })
});
// Apply limiters
app.use(globalLimiter);
app.post('/login', authLimiter, loginHandler);
app.post('/forgot-password', authLimiter, forgotPasswordHandler);
Also implement account lockout after repeated failures:
async function loginHandler(req, res) {
const { username, password } = req.body;
const user = await User.findOne({ username });
// Check if account is locked
if (user?.lockedUntil && user.lockedUntil > new Date()) {
const remainingTime = Math.ceil((user.lockedUntil - new Date()) / 60000);
return res.status(423).json({
error: `Account locked. Try again in ${remainingTime} minutes.`
});
}
if (user && await bcrypt.compare(password, user.password)) {
// Reset failed attempts on successful login
await User.updateOne(
{ _id: user._id },
{ $set: { failedAttempts: 0, lockedUntil: null } }
);
return res.json({ token: generateToken(user) });
}
// Track failed attempts
if (user) {
const failedAttempts = (user.failedAttempts || 0) + 1;
const update = { failedAttempts };
// Lock account after 5 failed attempts
if (failedAttempts >= 5) {
update.lockedUntil = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
}
await User.updateOne({ _id: user._id }, { $set: update });
}
res.status(401).json({ error: 'Invalid credentials' });
}
2. Predictable Resource Identifiers
The Problem:
Using sequential or predictable identifiers makes it easy for attackers to enumerate resources.
// INSECURE DESIGN: Sequential user IDs
// /api/users/1, /api/users/2, /api/users/3...
const user = await User.findById(req.params.id); // id = 1, 2, 3...
// INSECURE DESIGN: Predictable reset tokens
const resetToken = `reset-${userId}-${Date.now()}`;
Secure Design:
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
// Use UUIDs for resource identifiers
const newUser = new User({
publicId: uuidv4(), // 550e8400-e29b-41d4-a716-446655440000
// ...
});
// Use cryptographically random tokens
function generateSecureToken() {
return crypto.randomBytes(32).toString('hex'); // 64 character hex string
}
const resetToken = generateSecureToken();
// Store hashed version of token, send plain version to user
const hashedToken = crypto.createHash('sha256').update(resetToken).digest('hex');
3. Missing Business Logic Validation
The Problem:
Trusting client-side validation or not validating business rules on the server.
// INSECURE DESIGN: Trust client-provided price
app.post('/checkout', async (req, res) => {
const { items, total } = req.body; // Client sends the total!
await Order.create({
items,
total, // Attacker could send total: 0.01
userId: req.user.id
});
});
Secure Design:
// SECURE: Calculate total server-side
app.post('/checkout', async (req, res) => {
const { items } = req.body;
// Fetch current prices from database
const productIds = items.map(item => item.productId);
const products = await Product.find({ _id: { $in: productIds } });
// Create price lookup map
const priceMap = new Map(products.map(p => [p._id.toString(), p.price]));
// Calculate total server-side with validation
let total = 0;
for (const item of items) {
const price = priceMap.get(item.productId);
if (!price) {
return res.status(400).json({ error: `Invalid product: ${item.productId}` });
}
if (item.quantity < 1 || item.quantity > 100) {
return res.status(400).json({ error: 'Invalid quantity' });
}
total += price * item.quantity;
}
await Order.create({
items,
total, // Server-calculated, tamper-proof
userId: req.user.id
});
});
4. Insufficient Anti-Automation
The Problem:
Not protecting sensitive operations from automated abuse.
// INSECURE DESIGN: No CAPTCHA on registration
app.post('/register', async (req, res) => {
const user = await User.create(req.body);
res.json({ success: true });
});
// Bots can create thousands of fake accounts
Secure Design:
const axios = require('axios');
async function verifyCaptcha(token) {
const response = await axios.post(
'https://www.google.com/recaptcha/api/siteverify',
null,
{
params: {
secret: process.env.RECAPTCHA_SECRET,
response: token
}
}
);
return response.data.success && response.data.score >= 0.5;
}
app.post('/register', async (req, res) => {
// Verify CAPTCHA first
const captchaValid = await verifyCaptcha(req.body.captchaToken);
if (!captchaValid) {
return res.status(400).json({ error: 'CAPTCHA verification failed' });
}
const user = await User.create(req.body);
res.json({ success: true });
});
Secure Design Principles
1. Threat Modeling
Before writing code, identify potential threats using frameworks like STRIDE:
- Spoofing - Can someone impersonate a user?
- Tampering - Can data be modified in transit or at rest?
- Repudiation - Can actions be denied without proof?
- Information Disclosure - Can sensitive data leak?
- Denial of Service - Can the system be overwhelmed?
- Elevation of Privilege - Can users gain unauthorized access?
2. Defense in Depth
Don't rely on a single security control. Layer multiple defenses:
// Multiple layers protecting sensitive data
app.get('/api/admin/users',
authenticateRequest, // Layer 1: Must be logged in
requireRole('admin'), // Layer 2: Must be admin
rateLimit({ max: 100 }), // Layer 3: Rate limiting
validateQuery, // Layer 4: Input validation
auditLog, // Layer 5: Logging for detection
adminUsersHandler
);
3. Secure by Default
Default configurations should be secure. Users should have to explicitly enable risky features.
// Secure defaults for session configuration
const sessionConfig = {
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Default: HTTPS only
httpOnly: true, // Default: No JavaScript access
sameSite: 'strict', // Default: CSRF protection
maxAge: 3600000 // Default: 1 hour expiry
}
};
// Only allow insecure cookies in development
if (process.env.NODE_ENV === 'development') {
sessionConfig.cookie.secure = false;
}
4. Fail Securely
When errors occur, fail in a secure state:
// INSECURE: Fail open
async function checkPermission(userId, resource) {
try {
const hasAccess = await permissionService.check(userId, resource);
return hasAccess;
} catch (error) {
console.error('Permission check failed:', error);
return true; // WRONG: Allow access on error
}
}
// SECURE: Fail closed
async function checkPermission(userId, resource) {
try {
const hasAccess = await permissionService.check(userId, resource);
return hasAccess;
} catch (error) {
console.error('Permission check failed:', error);
return false; // CORRECT: Deny access on error
}
}
Key Takeaways
- Design security in from the start - Retrofitting security is expensive and often incomplete
- Perform threat modeling - Identify risks before they become vulnerabilities
- Never trust client input - Validate all business logic server-side
- Implement rate limiting - Protect against brute force and enumeration
- Use unpredictable identifiers - UUIDs and cryptographic tokens
- Apply defense in depth - Multiple layers of security controls
- Fail securely - Deny access when something goes wrong
- Secure by default - Require explicit action to enable risky features
Secure design requires thinking like an attacker during the planning phase. The effort invested in secure design pays dividends throughout the application's lifetime.