6 secure coding mistakes to avoid in 2026

Most security incidents start with ordinary code, not sophisticated attacks. Learn the most frequent coding mistakes that hide in plain sight and how to spot them.

Jul 22, 2026 • 7 Minute Read

Please set an alt value for this image...

Most security incidents don't start with a sophisticated attacker exploiting a zero-day. They start with an ordinary piece of code that you write under deadline pressure. While your code might look fine at first, secure coding mistakes often hide in little things like incorrect string concatenation, an incomplete password check, or an error message that gives away too much details about the system’s internals. 

The good news is that the same mistakes show up again and again, which means you can learn to spot them. This post walks through the most common secure coding mistakes I see in real codebases, why they happen, and what to do instead. The examples are in JavaScript, but the underlying problems are language-agnostic.

1. Trusting user input

This is the root cause behind most of the OWASP Top 10. Injection attacks, cross-site scripting, path traversal: they all come down to treating input as data when the runtime treats it as instructions.

The classic example is SQL injection:

      // Don't do this 
const query = `SELECT * FROM users WHERE email = '${email}'`;

    

If email contains ' OR '1'='1, your WHERE clause now matches every row. While it’s a well-known mistake, SQL injection has been on the OWASP Top 10 since the list existed. Why? Because the vulnerable code works well, it passes tests, and works well in production until someone feeds it hostile input.

The fix is parameterized queries:

      const query = 'SELECT * FROM users WHERE email = ?'; db.execute(query, [email]);
    

The same principle applies beyond SQL. Building shell commands from user input invites command injection. Interpolating input into HTML without escaping invites cross-site scripting (XSS) attacks. For example, using a filename from a request to read from disk invites path traversal:

      const path = require('path'); const fs = require('fs'); 

// Vulnerable: the filename comes straight from the request 
app.get('/files/:name', (req, res) => { 
	const filePath = path.join('/app/uploads', req.params.name);
	res.send(fs.readFileSync(filePath)); 
});
    

This looks harmless because path.join sounds like it should keep things tidy. However, it doesn't. If an attacker requests /files/..%2f..%2f..%2fetc%2fpasswd, those ../ segments resolve upward and traverse right out of the uploads directory. path.join('/app/uploads', '../../../etc/passwd') returns /etc/passwd, and you've just served the system's user list.

The fix is to resolve the final path and confirm it still lives inside the directory you intended:

Identify DNS issues such as CoreDNS failures or network misconfigurations

      app.get('/files/:name', (req, res) => { 
	const uploads = path.resolve('/app/uploads'); 
	const filePath = path.resolve(uploads, req.params.name); 

	// Reject anything that escaped the uploads directory 
	if (!filePath.startsWith(uploads + path.sep)) { 
		return res.status(400).send('Invalid filename'); 
	} 
	res.send(fs.readFileSync(filePath)); 
});
    

Remember: Any time user input ends up inside something that gets interpreted, whether that’s SQL, HTML, a filepath, or a shell command, you need a mechanism to keep the two separated or verify the outcomes of the output. You can use parameterized queries, templating engines with auto-escaping, or allowlists for file access. 

2. Inventing your own authentication system

Password handling is one of those areas where doing it yourself is almost always the wrong call.

Avoid storing passwords in plain text or hashing them with fast algorithms like MD5 or SHA-256. These outdated hashing algorithms are fast by design and therefore easy to brute-force with modern GPUs. 

Another common mistake is hashing passwords without a salt. No, not actual salt… It means that two users who picked the same password end up having the same hash in your database. This means when they try to log in, your system will find two user accounts for a single password, possibly accessing an incorrect account.


What's a salt?

A salt is a random value generated per password and mixed in before hashing, so the same password produces a different hash for every user. Without one, an attacker who steals your database can compare your hashes against a precomputed table of hashes for common passwords (a "rainbow table") and crack thousands of accounts at once. With one, they have to attack each account separately. The salt isn't secret; it gets stored next to the hash. It just has to be unique.


If you still want to handle passwords directly, use a well-vetted algorithm like bcrypt or Argon2:

      const bcrypt = require('bcrypt'); 
const hash = await bcrypt.hash(password, 12); const valid = await bcrypt.compare(attempt, hash);
    

These algorithms are deliberately slow and handle salting for you. But honestly, the better answer for most teams is to not own this problem. Delegate authentication to an identity provider or a battle-tested authentication library. 

3. Leaking information through errors and logs

Here's an example stack trace that made it to a production API response:

      Error: connect ECONNREFUSED 10.0.3.42:5432 [1]
    at TCPConnectWrap.afterConnect [2]
    ...
    at /app/src/services/billing/stripe-sync.js:114 [3, 4]
    

For many devs, this might not seem like a big problem. However, you are silently giving away a lot of information about the internals of your application. That one error message told an attacker the internal IP [1] of the database server, that it runs PostgreSQL [2], the project's directory structure [3], and that there's a Stripe integration [4] to poke at. None of that was secret in a formal sense, but reconnaissance is the first phase of every attack, and this response did the attacker's homework for them.

So, how do we fix this? As a best practice, log sensitive details server-side so you can access them using dedicated log analyzer tools. The user should only see a generic error message and optionally a correlation ID. By sharing a correlation ID, the client can contact a support team and you can look up the exact moment the error happened in your logs and get all necessary details. Details the end-user should not see.

      app.use((err, req, res, next) => {
  const errorId = crypto.randomUUID();
  logger.error({ errorId, err, path: req.path });
  res.status(500).json({ error: 'Something went wrong', errorId });
});

    

A subtle variant on leaking information about your system: login and password-reset endpoints that respond differently depending on whether an account exists or not. "Invalid password" versus "No such user" lets an attacker enumerate your entire user base. Again, to fix this problem you should return a generic message for both. 

4. Leaking secrets in source code

While you should not store any secret keys in your code, still, we sometimes make the mistake of committing a secret. It often happens through config files that were “temporary”, or hardcoded credentials to quickly test something (we are all human!). Sometimes, you just forgot to add your `.gitignore` file that prevents you from committing a config file like a `.env` secrets file

The part people underestimate: git remembers everything. Deleting a key in a follow-up commit doesn’t erase it from your git history. Anyone who can clone your repository can look for the key in your git history. Nowadays, automated bots are scraping public GitHub repos for accidentally leaked secrets and will exploit them in a matter of minutes.

You can add a pre-commit hook like secretlint that validates if a commit contains any secrets before storing it in the git history. 

      # Run as a pre-commit hook alongside your linter 
npm install --save-dev @secretlint/secretlint 
    

In general, to prevent leaking secrets, use a secrets manager or keep secrets in env variables, add secret scanning to your Continuous Integration (CI) pipeline, and if a key leaks, rotate it immediately. Don’t try to rewrite your git history, assume the secret is instantly compromised the moment it was pushed.

5. Broken access control: checking who you are, not what you may do

Authentication and authorization get mixed up constantly, and the gap between them is where a lot of breaches live. Authentication answers "who is this?" Authorization answers "is this person allowed to do this specific thing?"

The textbook failure is the insecure direct object reference (IDOR attack):

      // Authenticated? Yes. Authorized? Never checked. 
app.get('/api/invoices/:id', requireLogin, async (req, res) => { 
	const invoice = await Invoice.findById(req.params.id); res.json(invoice); 
});
    

If a user is logged in, they can read any invoice by changing the ID in the URL (`/api/invoices/:id`). If your IDs are sequential integers, they can enumerate all of them with a for loop. The fix is boring and essential: check ownership on every request.

      const invoice = await Invoice.findOne({
	 _id: req.params.id, 
	accountId: req.user.accountId
});
    

The same principle of verifying authorization applies to input validation: it's a mistake to validate only on the client side and skip server-side checks. Nothing stops a user from bypassing your client entirely and sending requests directly to your API. If your only validation lives in the client, an attacker can submit whatever data they want to your endpoint. Ultimately, every check that actually matters has to be enforced on the server.

6. Outdated dependencies (supply chain attacks)

Your application is mostly other people's code. A typical Node project pulls in hundreds of dependencies, each one running in production without you having read it. When a vulnerability is disclosed, attackers start scanning for unpatched targets within hours.

To protect yourself from using dependencies with known vulnerabilities, you can do the following:

In short, be skeptical when adding dependencies in the first place. A package with three downloads a week might not be the best choice for your project. It’s better to pick well-vetted dependencies, and even then, there’s always a risk of a compromised dependency. 

Conclusion: Don't treat security as an afterthought

The thread connecting all of these mistakes is that none of them announce themselves. Insecure code compiles, passes tests, and behaves perfectly for legitimate users. The feedback loop that catches these types of security issues doesn’t exist, until an attacker finds them for you.

That's why good security habits matter:

  • Treat all input as hostile. 

  • Reach for established libraries before writing security-sensitive code yourself. 

  • Check authorization on every request, not just authentication. 

  • Keep secrets out of your repository and dependencies up-to-date. 

  • Review code by asking "what happens if someone sends this endpoint something malicious?" rather than only "does this work?"

None of this requires being a security specialist. It requires knowing where the common traps are, and building the reflex to check for them before the code ships.


Want to learn more about secure coding best practices? Check out Pluralsight's Secure Coding learning path, as well as our language-specific paths for PythonJava, C#, Go, and more.


Michiel Mulders

Michiel M.

Michiel Mulders is a seasoned Web3 developer advocate and software engineer with over six years of blockchain experience, specializing in Node.js and Go. He has worked with Hedera Hashgraph, Algorand Foundation, Lunie, Lisk, and BigchainDB. As the founder of Docu Agency, Michiel leverages his development background to improve documentation strategy, advocating for "Docs developers love" to enhance the developer experience. Michiel also writes for platforms such as Sitepoint, Honeypot, and Hackernoon.

More about this author