Twelve months. Forty-something engagements. Authentication findings in almost all of them. Not the same ones — that's the interesting part. Each team made its own particular set of small decisions that added up to a way in.
I want to walk you through six of those decisions. Not as a checklist. As stories — anonymized, with the dates and names softened, but otherwise as they happened. If you build a SaaS product and you've shipped a login form, somewhere in here is a mirror.
One: the company that hashed passwords with MD5 in 2026
I'll start with the one that surprised me most. Mid-size B2B SaaS, Series B funded, sells to enterprise customers, has SOC 2 Type II. Their CTO is sharp. Their team is competent. They have all the trappings of a security-aware shop.
Their user passwords were stored as unsalted MD5.
I almost didn't believe it. I extracted a sample hash from a test account, opened hashcat, and ten seconds later had the plaintext. I tried five more accounts. Same thing. It wasn't a single legacy table — it was the whole user database, every account, every customer.
I asked the CTO about it on the call. He went quiet for about thirty seconds. Then he said: "We migrated from PHP 5 in 2017. The original system used MD5 for passwords. The plan was always to migrate to bcrypt on the next password change. Nobody changed their passwords. We forgot the plan existed."
This is what I find interesting about that story. There was no bad decision. There was a perfectly reasonable plan — re-hash on next login, get to bcrypt over a year, retire MD5. The plan failed because nobody tracked it after the engineer who wrote it left. The bug wasn't a choice. It was the absence of one, multiplied by nine years.
If you take one thing from this story, it should be that. The authentication bugs we find are very rarely written into the code by someone who thought they were doing the right thing. They are usually written into the code by someone who wrote a TODO that nobody read after they were gone.
(For what it's worth: in 2026, the only acceptable algorithms are Argon2id, bcrypt, or scrypt, in that order of preference. And pair them with a server-side pepper, not just a per-user salt. The pepper is what protects you when the database leaks but the application server doesn't.)
Two: the support tool that became the back door
Different engagement. Different company. Different team. Same outcome.
They had built a "support impersonation" feature. The internal support team could log in as any customer to debug issues. The feature was gated by an internal email allowlist — only people with @company.com emails could use it. The CTO told me, with reasonable pride, that they had explicitly threat-modeled this when building it.
What they hadn't threat-modeled was the password reset flow on the internal support tool.
The tool was hosted at support.company.com. The password reset flow accepted any email address and sent a reset link. The reset endpoint did not check whether the email it was resetting was an internal email. So I sent a reset to attacker+support@company.com — which their email gateway treated as a normal address, routed it to me, and gave me a working reset link. The link reset the password for an account I had just created. The account, because the support tool didn't double-check on login, was treated as an internal user. I could now impersonate any customer in their database.
Time from "no access" to "logged in as the largest customer's CFO": about eleven minutes.
The lesson — the one I keep finding versions of — is that internal tools get less security attention than customer-facing ones, and the access they grant is almost always larger. Your most dangerous endpoints are not the ones your customers see. They are the ones your support team uses, your finance team uses, your engineering team uses for debugging. Those are the endpoints attackers want.
Three: the MFA that wasn't
I tested an app that required MFA on every login. Solid OTP setup, working hardware key fallback, the whole thing. I tried to find a way past it for two days. I couldn't.
On day three, I noticed they had a "send recovery codes to my email" flow that triggered if a user said they had lost their authenticator. The recovery codes were ten-digit numbers. There were ten of them. They didn't expire. They were sent to whatever email the user had on file.
I confirmed: if I could change a user's email, and they had ever requested recovery codes that hadn't been deleted, I could request a fresh set sent to my new email and bypass MFA entirely.
Could I change a user's email? Yes — through a separate flow that required only the user's current password. The current password wasn't required to be re-entered after the session was established. So if I had a session (via, say, a stolen cookie from a malware-infected browser, or a forwarded session via the cross-tenant CSRF I had found earlier), I could change the email, request recovery codes, log in fresh with MFA bypassed, change the email back, and leave no audit trail.
The team's reaction when I demoed it: "But that requires three separate things to be wrong." It did. They were all wrong. Most account-takeover chains we build in engagements involve three or four steps. None of them, individually, looks like a critical bug. Together, they are exactly that.
This is the part of authentication testing that doesn't reduce to a checklist. You can have great password storage, great session management, great MFA, and great recovery flows — and still ship an account takeover chain if the four don't agree about who you are and what state you're in.
Four: the IDOR in the password reset
This one was almost embarrassing. The password reset endpoint was /api/users/{id}/reset. The endpoint required an authenticated session — fair enough. What it didn't require was that the authenticated session belong to user {id}.
So I signed up. I logged in as myself. I noted my user ID was, say, 8421. I sent a POST to /api/users/1/reset. The system reset user 1's password to whatever I specified. User 1 was the first account ever created on the platform. It was the founder's account.
I want to be precise about how this got into production. The endpoint had been written by a junior engineer. Their code review caught the authentication check ("you need to be logged in to reset a password — added a check"). It didn't catch the authorization check ("you need to be logged in as the user you're resetting"). The reviewer was senior. The reviewer was tired. The reviewer thought "authenticated = authorized" because that's what the rest of the codebase looked like.
Authorization bugs in authentication endpoints are the worst kind of finding, because they bypass the entire model. The model says "to do X to user Y, you must be Y." The bug says "we forgot to check the second half."
Five: the OAuth integration that trusted the wrong claim
The app supported "Sign in with Google." The OAuth flow worked. The state parameter was validated. The PKCE was in place. The token exchange used the correct grant type. Honestly, the OAuth code looked great.
The bug was in what came after the token exchange. When the app received the user profile back from Google, it looked at the email claim and used it to find or create a local account. If the email matched an existing account, the user was logged into that account.
The problem: Google's OAuth profile returns both email and email_verified. The app checked the first and ignored the second. Which meant: an attacker could create a Google account with the email victim@company.com — unverified, because Google never sent the verification — and use Sign in with Google to log into the victim's account on the SaaS app.
The fix is one line: check that email_verified === true. Most teams' OAuth code doesn't. We find this pattern often enough that it's now in our standard playbook.
What I think is going on here: developers reading OAuth documentation often skim past the "verification" bit, because the obvious reading of an email claim is that it's verified. Why else would the provider send it? The answer — because the standard distinguishes between "this user typed this email into our system" and "we verified the user controls this email" — is a footnote in most provider docs. It is, in practice, the bug.
Six: the session that lived too long
Different company. They had built an internal admin panel. The session timeout was set to "remember me for 90 days." When I asked why, the lead engineer said "Our admins were tired of logging in every day." Fair point. Bad answer.
The admin panel could revoke any user's account, refund any payment, modify any subscription, and read any customer's data. The "90 days" meant: every admin laptop with a stolen browser session cookie was a 90-day window for the attacker. Not "until they notice." Ninety actual days.
I'll spare you the rest of the engagement. We pivoted from one stolen session cookie (we got it via XSS in a different part of the product) into the admin panel, into a billing system, into the customer's payment integrations. None of the individual moves was technically difficult. The 90-day window was the entire weakness.
The pattern here is one I want to name explicitly. The convenience-versus-security tradeoff in authentication is almost always wrongly priced. Engineers and product teams default to long sessions because the user complaints about session length are loud and the security cost is invisible. Then the breach happens, and the post-mortem says "we should have had shorter sessions for admin tooling" — and everyone agrees, after the fact, that the convenience wasn't worth it.
If you can't measure session-related complaints against session-related risk in any rigorous way (and almost nobody can), default to short. Especially for admins. Always for admins.
The pattern across all six
If you re-read those stories, the bugs aren't really about cryptography. The MD5 case is the closest, and even that one is more about institutional memory than about algorithms. The rest are about decisions made under pressure, reviewed by tired people, written down somewhere that nobody read, or built on assumptions that turned out to be different from what the documentation actually said.
The pattern I want you to take from this is something like: authentication is not a feature you implement once and then own. It's a set of decisions that have to be re-evaluated every time the product changes. Adding a new admin tool? Re-evaluate. Migrating from one auth provider to another? Re-evaluate. Shipping a new OAuth integration? Re-evaluate. The bugs we find live in the gaps between when a decision was right and when it stopped being right.
If you're a founder reading this — the question to ask your team isn't "do we have auth bugs?" Most teams' honest answer is "we don't think so, but we haven't looked recently." The better question is "when did we last specifically pressure-test the authentication surface, end to end, against the chains we'd actually build against ourselves?" If the honest answer is "never," that's the engagement to commission.
If you're an engineer reading this — the version for you is similar. You probably have one of these bugs right now. So do we, on most of our own projects. The bar isn't "no bugs." The bar is "we know about the gaps, we have a plan, and we know who's tracking the plan." Most of the stories above are about projects that had none of those three.
The short version, for the people skimming
The six findings, condensed:
- MD5 password storage that survived a migration plan nobody owned.
- Internal support tool with weak password reset, leading to customer impersonation.
- MFA bypass through recovery codes + email change without re-auth.
- IDOR in
/users/{id}/resetletting any user reset any account. - OAuth trusting
emailwithout checkingemail_verified. - 90-day admin sessions turning a single XSS into a full backend compromise.
If your team can confidently say "we don't have any of these," we'd love to test that assertion. If they can't, you already know the next step.
One last thought
The reason authentication bugs persist isn't that engineers don't care about authentication. It's that authentication touches everything — the login form, the API, the admin panel, the support tool, the OAuth integration, the password reset, the MFA, the session, the recovery codes, the device trust — and each of those is owned by a different team, written at a different time, reviewed by a different person.
The bugs live in the seams. The seams are everywhere. The way you find them is to look at the system as a whole, not at the parts. Which is most of what we do for a living.
If any of the stories above sounded familiar — or if any of them made you suddenly want to check something — that's the article doing its job.