In one line
I added multi-factor authentication to a payments platform that had only ever had a password, building the whole feature as one clean vertical slice - entity, repository, service, handler, route, email template and config - rather than bolting a second factor onto a login flow that was never designed for one.
The problem
The legacy platform authenticated users with a password and nothing else. For a system sitting in front of payments data, a single factor is the kind of gap that turns one leaked credential into a full account compromise. The new stack needed real multi-factor authentication, and it needed to be something a user could actually turn on and use, not a checkbox.
There was no existing second-factor code to migrate. This was net-new security functionality on a fresh architecture, which is the good news and the responsibility at once: nothing to copy, nothing to blame, and no room to get an auth flow subtly wrong.
What I built
I owned the feature as a vertical slice, top to bottom, so every layer was consistent with the platform's conventions rather than special-cased.
The second factors
Two, so users are not forced onto one channel:
- Email OTP - a one-time code delivered by email, with its own message template, for users without an authenticator app.
- Authenticator-app OTP - a time-based one-time password (TOTP) for users who want a proper second factor that does not depend on email delivery.
The enrolment flow
MFA is only useful if turning it on is a real flow, not a database flag someone
sets by hand. I built activation via both email and the mobile app, tracked with
an isMfaActivated field on the user, so the system knows a user's true MFA state
and enforces it on subsequent logins.
The vertical slice
The one feature touched every layer, and I built each to the platform's standard:
- Entity - the MFA state on the user record.
- Request / response DTOs - typed contracts for the OTP exchange, carrying the OpenAPI metadata the rest of the platform relies on.
- Repository and service - the OTP lifecycle, kept behind a service boundary rather than smeared through the handler.
- Handler and route - the
authenticate-mfaand send-code endpoints, named and shaped like every other endpoint on the platform. - Email template and config - so the OTP email is a first-class, configurable part of the system.
Environment-aware recovery
Password recovery is the soft underbelly of any auth system. I made the forgot-password flow environment-specific, so a reset link generated in one environment cannot be used to walk into another - a small detail that is exactly the kind of thing that goes wrong when password reset is treated as an afterthought.
Why it mattered
It closed a real security gap on a payments system. Going from a bare password to genuine multi-factor authentication is the difference between one leaked credential being a catastrophe and being an inconvenience.
It was built to the platform's conventions, not around them. Because the feature went through the same DTO, service and OpenAPI patterns as everything else, it did not become a special case that the next person has to learn separately. It reads like the rest of the system.
It gave users a real choice. Two second factors, an actual enrolment flow, and recovery that does not undermine the thing it is recovering - the feature is usable, not just present.
What I would tell someone starting this
Build auth as a vertical slice, not a bolt-on. The failures in authentication systems live in the seams - between the token and the session, between reset and login. Owning every layer of the one feature is how you keep the seams honest.
Password recovery is part of the auth system, not a footnote. The strongest second factor in the world does not help if the reset flow is a side door. Make it environment-aware and treat it with the same care as the login path.