The Day I Logged in With a Hash

A SimpleLogin Story

Table of Contents

I’ve said it before: a single password per account isn’t enough anymore.

If you care even a little about your online footprint, you should be thinking about one email per account too. Why? Because your email address is your identity online. And just like password reuse, reusing the same email everywhere makes you predictable and easy to track.

For years, I tried to solve this with Gmail “+” aliases. You know the trick: . It works… until it doesn’t. Bad actors now routinely strip the “+” part when they test leaked creds (source). Suddenly, my neat aliasing trick was useless.

So I went hunting for a better option.


🚀 Enter SimpleLogin (and a Throwback)

I’ve been looking for a platform like SimpleLogin for years. In fact, as a student I even built my own: Keemail.

Spoiler: it was bad.

  • No polish.

  • Missing core features.

  • Wouldn’t survive today’s email security standards.

But the idea was there — unique emails per service, created via API, privacy by default.

When I discovered SimpleLogin, I was instantly sold. It did everything I wanted, way better. I’ve been self-hosting it for years now. Proton eventually acquired them and integrated it into their suite, but the original team deserves huge kudos. It’s one of those rare projects that just makes sense.

For the uninitiated: SimpleLogin lets you create unlimited email aliases on the fly. You hand those out instead of your real email. If one gets spammed, you just kill it. Clean, simple, brilliant.


🔑 The Problem: Losing 2FA

Fast forward to last year. I changed phones and lost access to the 2FA on my main SimpleLogin account. No big deal — that’s what recovery codes are for, right?

Except… I hadn’t saved mine. (Yes, yes, I know. Do as I say, not as I do.)

So now I was stuck. Out of curiosity — and, let’s be honest, a bit of fun — I wondered:

Could I regenerate the codes? Or at least poke around in the DB to see what was stored?


🕵️ The Discovery

I checked the database. Good news: the recovery codes weren’t in plain text. They were hashed. Nice touch.

Bad news (or so I thought): that meant I couldn’t just copy-paste one.

But then I tried something dumb. I copied the full hashed recovery code from the DB and pasted it into the login form.

It worked.

I thought it was a fluke. Tried again. Still worked.

At this point my brain went: wait, what?


📜 The Code Behind the Bug

A quick look at the source confirmed it. In the recovery code lookup:

def find_by_user_code(cls, user: User, code: str):
    hashed_code = cls._hash_code(code)
    # TODO: Only return hashed codes once there aren't unhashed codes in the db.
    found_code = cls.get_by(user_id=user.id, code=hashed_code)
    if found_code:
        return found_code
    return cls.get_by(user_id=user.id, code=code)

Translation:

  • They hash the user input.

  • But if that doesn’t match, they also check the raw code column.

So the hashed recovery code itself was valid input.

That’s the equivalent of storing passwords as hashes… but then letting you log in with the hash.


📨 Disclosure (and Frustration)

I followed the responsible disclosure process and emailed SimpleLogin/Proton.

Their first answer?

If an attacker has access to the DB, they can impersonate a user anyway. 2FA isn’t end-to-end encryption.

True, but… missing the point.

The point of hashing recovery codes is to prevent them from being directly usable if the DB leaks. By letting the hash itself authenticate, you’ve basically made them plain-text again.

Imagine if password hashes worked the same way. You’d laugh someone out of the room for suggesting it.

I explained this in a follow-up email. Then… silence.


🛠️ The Fix

Three days later, still no response. So I opened a public PR.

The fix was straightforward: only ever compare hashed(user_input) to stored_hashed(code). Nothing else.

It was merged within a week. Issue closed.


💡 Lessons Learned

A few takeaways from this little adventure:

  • Even great products can make silly mistakes. I still love and use SimpleLogin. But this bug shows that no stack is immune to bad design shortcuts.

  • Hashing is meaningless if you treat the hash like the secret. This feels obvious, but it’s exactly what happened here.

  • Disclosure can be frustrating. I don’t blame the SimpleLogin team — I get it, priorities, context, “bigger picture.” But sometimes you need to insist. Politely, but firmly.

  • Student me wasn’t totally wrong. My Keemail project failed, but the core idea was right. Seeing SimpleLogin thrive, and even contributing a patch to fix a bug, was a nice full-circle moment.


🔒 Why This Matters

This isn’t about dunking on a product I like. It’s about showing how small design choices in security have big consequences.

2FA is supposed to protect against stolen passwords. Recovery codes are supposed to protect against losing 2FA. But if your recovery codes can themselves be bypassed, the whole system weakens.

And that’s a lesson worth remembering far beyond SimpleLogin.

Comments