September 13, 2026
10 Popular Security Practices That Do More Harm Than Good
These were good security practices 10 years ago. Attackers evolved. Our code did not.

By Vinod Pal
9 min read
I am a software developer and security enthusiast. I have built authentication and payment systems for banks. Getting security wrong there gets expensive fast.
I have also sat on the other side of the table, reviewing other teams' code during security audits.
Both roles taught me the same lesson. Most insecure code does not come from careless developers.
It comes from careful developers following advice that used to be correct.
I have also made some of the security mistakes in my code earlier. And I have learned my lesson the hard way.
Non-medium members click here to read for free.
Over years of auditing code and writing it, I have seen practices that sound right but are wrong.
Here are ten of those, and what I use now instead.
1. Expiring Passwords Every 90 Days
You wrote this check in your code. You own passwordChangedAt and the redirect that fires once 90 days pass.
Here is what your users do with it.
They pick Spring2024! in March. In June they pick Summer2024!. Anyone who steals one password can guess the next four.
Now the part most people miss. Expiry assumes an attacker sits on a stolen password for weeks.
They do not. Stolen credentials get used the same day, often within the hour. By the time your 90-day timer fires, the attacker has come and gone.
So the check trains bad passwords to close a window that was already shut.
Microsoft dropped password expiry from the Windows security baseline in 2019. They called it an obsolete mitigation of very low value. NIST banned it outright in 2025.
Your codebase still has the check.
What I do now. Delete it. Set a 15-character minimum. Drop the symbol rules. Check every new password against a list of leaked ones.
If a regulation in your contract still demands rotation, comply. You will not win that fight.
Just know the rule protects compliance, not the account.
2. Blocking Paste in the Password Field
Someone added onpaste="return false" to stop shoulder surfing. Watch what it does.
A user cannot remember z$K9#pQvL2@mX7!y. Your blocker stops them from pasting it. So they type Summer@2026 instead.
You did not prevent an attack. You downgraded the password.
The quieter version of this bug is worse. Somewhere in your code, a password gets cut to 16 characters before hashing.
Two users with different long passwords now share a hash. The user never sees it, your tests never catch it, and the login still works.
What I do now. Allow paste. Accept 64 characters or more, spaces included, and never truncate before hashing.
The hash output is a fixed size anyway. Long input costs you nothing.
3. Storing Security Questions in Your Recovery Table
I still find this schema in code written after 2024:
user_security_answers
user_id
question_id
answer_hashuser_security_answers
user_id
question_id
answer_hashHere is the attack. I want into your account, and you have a hardware key, so the login is solid.
I skip it. I click "forgot password" and answer three questions.
Your mother's maiden name is in public records. Your first school is on LinkedIn. Your first pet was in an Instagram caption in 2016.
That is the whole point. Recovery is a second front door, and most teams build it weaker than the first one.
You can ship the strongest login in the world and still lose every account to a trivia question. NIST bans this now.
What I do now. Give users single-use recovery codes at signup and tell them to save those in a password manager.
For high-value accounts, add a delay. A 24-hour hold on recovery kills most takeover attempts, because attackers need speed.
4. Locking the Account After Five Failed Attempts
This one gets built as a defense. But it works as a weapon for attackers.
I know your support lead's email. I send six bad passwords to their account. They cannot log in.
Now I script it against every username I can find. You have an outage, and I never exploited anything.
The attack it was built to stop is not the attack you face. Nobody guesses one account a thousand times.
They take one common password and try it across ten thousand accounts. One attempt each. Your counter never moves.
So lockout blocks an attack nobody runs and enables one anybody can run.
What I do now. Slow the attacker instead of locking the user. Add a delay that doubles with each failure, counted on the account and the source together.
Watch failures across accounts. One password hitting many users is the real signal. Ask for extra verification on a new device instead of locking the door.
5. Shipping SMS Codes and Calling It MFA
You built the one-time code flow. Six digits, sent by text, valid for five minutes.
Here is a real phishing attack against it.
The user lands on a page that looks like your login. They type their password. My server types it into your real site at the same moment.
Your site texts a code. My fake page asks for it. The user types it in, I forward it, and I am inside.
The code was valid. The user was careful. It did not matter, because nothing tied that code to the site they were actually on.
That is what passkeys fix. The key refuses to work on a domain it was not created for, so my fake page gets nothing.
SIM swap breaks SMS a second way. Someone talks a mobile operator into moving the number, and your codes go to them.
What I do now. Match login strength to what the account can reach, and write that mapping down.
Passkeys for admin consoles, production access, and anything touching customer money.
On the last migration I worked on, the code was the easy part. The problem was people. Some users had no second device. Some operations logins were shared by three people.
We ran passkeys alongside SMS for two months and moved high-value roles first. Plan for that overlap or the rollout stalls.
6. Sanitizing Input Instead of Encoding Output
Remove the dangerous characters at the door, and the app stays clean. That is the theory.
The theory breaks on one point. The same text is dangerous in different ways, and it depends on where it lands.
O'Brien breaks a SQL query. <b> breaks a web page. javascript: breaks a link. One function at the door cannot know which one is coming.
So it does the only thing it can. It strips everything, and your customer named O'Brien cannot save his name.
Then someone adds an exception for apostrophes to fix the complaint. The hole is back, and now it looks intentional.
What I do now. Check the shape on the way in. Type, length, format, then reject.
Escape on the way out, based on destination. Parameterized queries for the database, template engine for the HTML.
Input checks protect your data. Output escaping protects your users.
7. Arguing About Token Storage While Shipping Tokens You Cannot Cancel
Teams burn sprints on where to keep the login token in the browser. Storage does matter. It just cannot carry the weight teams put on it.
If I can run my script inside your page, I do not need to read the token. I just send requests from the user's own browser, with their own session.
Hiding the token from JavaScript makes me work a little harder. It does not stop me.
Here is the gap nobody is arguing about. Say a token does leak. Can you kill it?
In most systems I have audited, no. The token works until it expires, and it expires in days.
So pick your storage carefully, then keep going.
Four things decide how bad the leak gets. Token lifetime, rotation, revocation, and whether you stopped the script injection at all.
What I do now. Make the short-lived token actually short. Minutes.
Hand out a new refresh token on every use. If an old one ever comes back, someone copied it, so kill the whole chain and force a fresh login.
Add a Content Security Policy, which tells the browser what your page may load. The injected script then has nowhere to send what it steals.
8. Checking Permissions in Middleware and Nowhere Else
This is the most common real finding I hit in audits, and it is not close.
// Middleware confirms the user is logged in.
// Nothing confirms this order belongs to them.
GET /api/orders/8842// Middleware confirms the user is logged in.
// Nothing confirms this order belongs to them.
GET /api/orders/8842Log in as any customer. Change 8842 to 8841. Read someone else's invoice.
The reason it survives review is the timing. Middleware runs before you load the record, so it cannot possibly know who owns it.
It can only answer "are you someone". The question you needed answered was "is this yours".
Switching to random IDs does not fix it. It makes guessing harder. But nobody needs to guess. IDs leak through logs, referrers, and screenshots.
What I do now. Move the check to where you fetch the data.
Scope the query itself. WHERE id = ? AND tenant_id = ? beats a permission check three function calls later.
Then write the test nobody writes. One user, one record they do not own, one rejection.
9. Catching Broad Exceptions So the Request Fails Safe
You wrapped the handler to stop the API from throwing errors at users.
try {
return repo.getDocumentsFor(userId);
} catch (Exception e) {
log.warn("fetch failed");
return [];
}try {
return repo.getDocumentsFor(userId);
} catch (Exception e) {
log.warn("fetch failed");
return [];
}Now a permission check fails, and the API returns success with an empty list.
Think about what that erases. The user sees "no documents" instead of "access denied".
Your dashboard sees a 200. Your alerts stay quiet. Someone probes your API for records they should not reach. Every response looks clean. You never know they tried.
I see this pattern more often since AI agents started writing handlers. Reviewers approve it because it looks careful.
What I do now. Catch specific exception types and let the rest rise.
Never return a success shape from a failed security check. Return a rejection, and log who asked, for what, and why you said no.
10. Reflecting the Origin Header to Make CORS Stop Complaining
The browser blocks your API call. You search the error and find a two-line fix.
response.header("Access-Control-Allow-Origin", request.header("Origin"));
response.header("Access-Control-Allow-Credentials", "true");response.header("Access-Control-Allow-Origin", request.header("Origin"));
response.header("Access-Control-Allow-Credentials", "true");Read those two lines together. You echo back whichever origin asked, and you allow cookies with it.
CORS is not access control. It is a browser rule about which origins may read your responses. So what an attacker gains depends on two things. What your API exposes, and what the browser allows for that request type.
That is still enough. A logged-in user visits another page. That page calls your API with their session. The browser hands over the response.
So a user logged into your app visits any other page. That page calls your API, your server says yes, and it reads their account.
The browser rule you just switched off existed to stop exactly that.
Wildcard matching causes the same thing with more steps. *.example.com includes the forgotten subdomain someone parked in 2021.
What I do now. Keep a fixed list of allowed origins and compare against it. No patterns, no reflection.
If that list feels too long to maintain, you have learned something useful before an attacker did.
What All Ten Have in Common
All ten of these stop a slow attacker. Someone guessing one account, trying again and again, waiting for weeks.
Nobody attacks like that now. They use a stolen password the same hour. They pass your SMS code along while the user is still typing. They change one number in a URL.
So ask two questions about any security code you write. What attack does this stop? Does anyone still do that attack?
If you cannot answer the first one, the code is just for show. If the answer to the second one is no, it is only costing you.
Pick two to fix this month. Check ownership in the query and make your tokens expire in minutes.
Then delete the paste blocker. It's 2026.