Top 10 Common Security Mistakes Developers Make
Security isn't a feature you add at the end. It's a habit you build from the start — and these are the habits most developers skip.
Loading articles...
Security isn't a feature you add at the end. It's a habit you build from the start — and these are the habits most developers skip.

Most security breaches don't happen because attackers are brilliant. They happen because developers made well-known, preventable mistakes under deadline pressure, with incomplete context, or simply without anyone telling them better.
This article covers the ten most common security mistakes developers make — not to shame anyone, but to build the kind of awareness that prevents the next breach. Each section includes what goes wrong, why it's dangerous, and how to fix it.
A developer commits a .env file to a public GitHub repository. Or hardcodes a Stripe secret key directly in the frontend JavaScript. Or pushes AWS credentials into a Docker image. These are not rare edge cases — they happen constantly, and automated bots scan GitHub 24/7 looking for exactly these patterns.
// ❌ Never do this
const stripe = require('stripe')('sk_live_4eC39HqLyjWDarjtT1zdp7dc');
const response = await fetch(`https://api.openai.com/v1/chat/completions`, {
headers: { Authorization: `Bearer sk-proj-...` } // hardcoded secret
});
Once a secret is in a public repository — even for 30 seconds before you delete it — it should be considered compromised. Bots index commits faster than humans can react. Attackers with your AWS keys can spin up thousands of GPU instances in minutes. Your bill will be enormous; the damage may be irreversible.
.gitignore to exclude .env files from day one# .gitignore — add these from the very first commit
.env
.env.local
.env.production
*.pem
A developer stores user passwords as plain text in the database. Or uses MD5/SHA-1 — which are not password hashing algorithms, despite what Stack Overflow answers from 2009 suggest. When the database is breached (and for any sufficiently successful product, it eventually will be), every user's password is immediately compromised.
# ❌ Plain text — catastrophic
user.password = request.form['password']
# ❌ SHA-256 — still wrong for passwords (fast = bad for hashing passwords)
import hashlib
user.password = hashlib.sha256(password.encode()).hexdigest()
Passwords stored without proper hashing are recovered instantly via precomputed rainbow tables. Even unsalted bcrypt can be cracked faster than you think on modern hardware. The goal isn't just "not plain text" — it's making brute-force attacks computationally infeasible.
Use a purpose-built, slow password hashing algorithm. The three acceptable choices today are:
| Algorithm | When to use |
|---|---|
| bcrypt | Most languages, widely supported, battle-tested |
| Argon2id | Preferred for new systems; winner of Password Hashing Competition |
| scrypt | Good alternative; memory-hard |
# ✅ Python — using bcrypt
import bcrypt
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12))
# ✅ Node.js — using argon2
const argon2 = require('argon2');
const hash = await argon2.hash(password);
Never implement your own hashing scheme. The library authors have spent years thinking about this; you haven't.
The API runs on HTTP. The login form submits credentials over an unencrypted connection. The production app uses HTTPS, but the staging environment doesn't — and someone tests with production credentials on staging. Mixed content warnings are silently ignored.
On any network the user doesn't fully control — a coffee shop Wi-Fi, a corporate proxy, a compromised router — HTTP traffic is trivially intercepted and modified. Credentials, session tokens, and sensitive data travel in plain text. Even worse: an attacker performing a man-in-the-middle attack can inject malicious scripts into your HTML responses before they reach the user.
Strict-Transport-Security (HSTS) headerSecure# nginx — force HTTPS redirect
server {
listen 80;
return 301 https://$host$request_uri;
}
# HSTS header — tells browsers to always use HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
The frontend form validates that an email address looks correct, that a price is positive, that a quantity doesn't exceed stock. The backend receives the data and trusts it completely — because "the frontend already checked." An attacker bypasses the UI entirely and sends raw HTTP requests with arbitrary payloads.
// ❌ Backend that trusts frontend validation
app.post('/purchase', (req, res) => {
const { itemId, quantity, price } = req.body;
// Assumes price came from the UI and is correct
chargeUser(req.user, price * quantity);
});
Client-side validation exists for user experience. Backend validation exists for security. They are not interchangeable. Any data that travels over the network can be modified — by the user, by a proxy, by automated tooling. A single missing backend check can allow an attacker to purchase items for $0.00, submit negative quantities, or inject malicious strings.
Treat every incoming request as if it came from a hostile actor. Validate every field server-side: type, range, format, length, and business logic constraints.
# ✅ FastAPI with Pydantic — server-side validation
from pydantic import BaseModel, condecimal, conint
class PurchaseRequest(BaseModel):
item_id: int
quantity: conint(gt=0, le=100) # must be 1–100
@app.post("/purchase")
def purchase(req: PurchaseRequest, user: User = Depends(get_current_user)):
item = get_item_or_404(req.item_id)
price = item.price # price comes from the database, never from the client
charge_user(user, price * req.quantity)
User input is concatenated directly into SQL queries. This is one of the oldest vulnerabilities in web development — it has been on the OWASP Top 10 for over two decades — and it still appears in production code every year.
# ❌ SQL injection waiting to happen
username = request.args.get('username')
query = f"SELECT * FROM users WHERE username = '{username}'"
db.execute(query)
# Attacker sends: username = ' OR '1'='1
# Resulting query: SELECT * FROM users WHERE username = '' OR '1'='1'
# Returns all users in the database
SQL injection can allow an attacker to bypass authentication, read arbitrary data from the database, modify or delete records, and in some configurations, execute operating system commands. A single vulnerable endpoint can expose your entire data layer.
Always use parameterized queries or an ORM. Never concatenate user input into query strings.
# ✅ Parameterized query
cursor.execute("SELECT * FROM users WHERE username = %s", (username,))
# ✅ SQLAlchemy ORM
user = db.query(User).filter(User.username == username).first()
An application allows users to upload files without validating type, content, or destination. An attacker uploads a PHP file disguised as a profile picture. The server executes it. Alternatively, a user uploads a 10GB file and exhausts disk space, or uploads an SVG containing embedded JavaScript that executes in other users' browsers.
// ❌ Accepting uploads without any validation
app.post('/upload', upload.single('file'), (req, res) => {
// Trusts the filename and MIME type from the client
fs.renameSync(req.file.path, `./uploads/${req.file.originalname}`);
res.json({ url: `/uploads/${req.file.originalname}` });
});
Unrestricted file upload is a direct path to Remote Code Execution (RCE) — the worst possible outcome in application security. Even without RCE, attackers can use uploads for stored XSS, path traversal, or denial-of-service attacks.
Content-Disposition: attachment to prevent browser execution// ✅ Safer upload handling
const { v4: uuidv4 } = require('uuid');
const fileType = require('file-type');
app.post('/upload', upload.single('file'), async (req, res) => {
const buffer = fs.readFileSync(req.file.path);
const type = await fileType.fromBuffer(buffer);
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!type || !allowedTypes.includes(type.mime)) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Invalid file type' });
}
const safeFilename = `${uuidv4()}.${type.ext}`;
await s3.upload({ Key: safeFilename, Body: buffer }).promise();
res.json({ key: safeFilename });
});
Session tokens are short and predictable. JWTs use the none algorithm. Tokens never expire. Session IDs aren't regenerated after login. Password reset tokens are valid indefinitely. These mistakes are individually subtle but collectively devastating.
// ❌ Accepting "none" algorithm in JWT — a famous vulnerability
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256', 'none'] // 'none' allows unsigned tokens
});
Broken authentication lets attackers impersonate legitimate users — including administrators. Session fixation attacks, token theft, and credential stuffing all exploit weaknesses in how applications issue and validate identity tokens.
none// ✅ Explicit algorithm whitelist for JWT
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'] // only allow what you intend
});
User-supplied content is rendered in HTML without sanitization. A malicious user posts a comment containing <script>document.cookie</script>. Everyone who views that comment now has their session cookie sent to the attacker's server. This is stored XSS — but reflected and DOM-based XSS variants are equally common.
<!-- ❌ Dangerous — renders arbitrary HTML from user input -->
<div dangerouslySetInnerHTML={{ __html: userComment }} />
XSS allows an attacker to execute arbitrary JavaScript in the context of your application, in your users' browsers. This means stealing session tokens, performing actions on behalf of the user, redirecting to phishing pages, or logging keystrokes — all without any visible indication to the user.
dangerouslySetInnerHTML, innerHTML, eval(), or document.write() with untrusted content// ✅ Sanitize before rendering rich HTML
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userComment);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
# CSP header — prevents inline script execution and restricts sources
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com;
A request hits an unhandled exception and the server returns a full stack trace, database schema details, or internal file paths. Sometimes the error includes the SQL query that failed — including the table name, column names, and query structure. This information is a gift to an attacker performing reconnaissance.
// ❌ What a verbose production error looks like
{
"error": "PG::UndefinedColumn: ERROR: column users.password_hash does not exist",
"query": "SELECT id, email, password_hash FROM users WHERE email = $1",
"backtrace": [
"app/models/user.rb:42:in `authenticate'",
"app/controllers/sessions_controller.rb:17:in `create'"
]
}
Stack traces reveal your technology stack, file structure, dependency versions, and internal logic. This dramatically reduces the effort required to find and exploit other vulnerabilities. Information leakage is often the first step in a targeted attack chain.
// ✅ Generic client error, detailed server log
app.use((err, req, res, next) => {
logger.error({ err, requestId: req.id }); // detailed log — server only
res.status(500).json({ error: 'Something went wrong', requestId: req.id });
});
The application works perfectly. It's on HTTPS. Passwords are hashed. The SQL queries are parameterized. But the HTTP response headers are missing a dozen security directives that browsers rely on to protect users. No Content-Security-Policy. No X-Frame-Options. No Referrer-Policy. The application is technically functional but defensively naked.
Security headers are the browser-side line of defense. Without them:
Add these headers to every response. Use the Helmet middleware in Node.js or equivalent in your framework:
// ✅ Node.js / Express — using Helmet
const helmet = require('helmet');
app.use(helmet()); // sets sensible defaults for all major security headers
# The headers Helmet sets (among others):
Content-Security-Policy: default-src 'self'
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=()
Use securityheaders.com to audit your current headers against best practices.
| # | Mistake | Fix |
|---|---|---|
| 1 | API keys in code | Environment variables + secrets manager |
| 2 | Passwords without hashing | bcrypt or Argon2id |
| 3 | No HTTPS | Let's Encrypt + HSTS header |
| 4 | Client-only validation | Always validate server-side |
| 5 | SQL injection | Parameterized queries / ORM |
| 6 | Insecure file uploads | Validate magic bytes, randomize name, use object storage |
| 7 | Broken authentication | Crypto-random tokens, explicit JWT algorithms |
| 8 | XSS | Framework escaping + DOMPurify + CSP |
| 9 | Verbose error messages | Generic client errors, detailed server logs |
| 10 | Missing security headers | Helmet (Node) or framework equivalent |
Security isn't glamorous work. It doesn't ship features. It doesn't impress anyone in a demo. But the absence of it — discovered after a breach — is one of the most devastating things that can happen to a product and the people who trust it with their data.
The good news: most of these mistakes are easy to fix once you know about them. The hard part isn't the implementation. It's building the habit of asking "what could go wrong here?" before you merge.
Start with this list. Add automated checks to your CI pipeline. Run tools like OWASP ZAP, Snyk, or Semgrep on your codebase. Make security review a normal part of code review, not an afterthought.
The attacker only needs to find one mistake. You need to avoid all of them.

Create elementAI Explainer Videos That Convert With Simple Text Prompts.
Learn More