How I added two-factor authentication to my website

A practical look at the sign-in flow behind this website: passwords, six-digit authenticator codes, recovery codes, and the small details that matter.

Illustration of a website sign-in form connected to an authenticator app, titled A password is only the first step.
Two-factor authentication for the .NET and Angular admin area.

When I built the admin area for this website, I had a fairly small requirement: I wanted to sign in, manage my content, and get on with my day.

But that admin area can change everything visitors see. A password alone felt like too much trust in one check. So I added a second step: a six-digit code from an authenticator app.

This post walks through that implementation in .NET and Angular. The code samples are shortened extracts from this website, not a complete authentication system to copy into another application.

What the extra code actually does

TOTP stands for time-based one-time password. The name sounds more complicated than the experience: open an authenticator app, read the current code, and enter it after your password.

During setup, the website and the authenticator share a randomly generated secret. After that, both use that secret and the current time to calculate matching codes. The app does not need to ask the website for a new code every time.

My implementation uses six digits and a 30-second interval. The underlying approach is described in RFC 6238.

There is one distinction worth making early: the six-digit code is temporary; the setup secret is not. Anyone who gets that secret can generate future codes. That makes the setup screen, including its QR code, sensitive.

A correct password is only the first step

I split sign-in into two checks:

  1. Verify the username and password on the server.
  2. Verify the authenticator code before granting admin access.

Between them, the browser receives a short-lived challenge. It is a reference to an unfinished sign-in, not an admin session.

Sign-in flow: password check, short-lived challenge, authenticator code, then admin session.

Here is a shortened extract from the password step:

var token = AuthenticationSecrets.RandomToken();

var challenge = new MfaChallenge
{
    UserId = user.Id,
    TokenHash = AuthenticationSecrets.Hash(token),
    BrowserBindingHash = AuthenticationSecrets.Hash(access.BrowserBinding!),
    ExpiresAtUtc = Now.AddMinutes(5)
};

database.MfaChallenges.Add(challenge);

return new AuthResult(
    new LoginView("Mfa", token, challenge.ExpiresAtUtc));

This runs after the password has been checked. The database keeps a hash of the challenge token, and the challenge is tied to the browser that started the sign-in. It also expires, so an unfinished attempt cannot sit around indefinitely.

Angular reads the Mfa next action and shows the code field. The important decision stays on the server: displaying the next screen does not give the browser permission to use admin endpoints.

For an account that has not set up an authenticator yet, the next action is MfaSetup. That account gets restricted setup access, rather than full admin access.

Setting up the authenticator

The setup starts with a new random secret. I use Otp.NET for the TOTP work, rather than writing the algorithm myself:

public static string GenerateTotpSecret()
{
    return Base32Encoding.ToString(
        RandomNumberGenerator.GetBytes(20));
}

The admin interface displays a QR code and a manual setup key. I can scan the QR code with an authenticator app, then enter the first code to confirm that setup worked.

I do not enable two-factor authentication just because the QR code was displayed. The server has to verify that first code. Once setup is confirmed, the application shows recovery codes and clears the temporary sign-in ticket. I then sign in through the normal password-and-code flow.

The secret also needs protection in storage. In this project, that part uses ASP.NET Core Data Protection:

public string ProtectTotp(string secret)
{
    return provider
        .CreateProtector("BujarAdemi.Totp.v1")
        .Protect(secret);
}

Unlike a password, this secret must be recoverable by the server to check future codes. Protecting it means the application also needs its Data Protection keys. Those keys have to survive deployments and be backed up securely; keeping the database alone is not enough.

Checking the code, and refusing to reuse it

The main verification helper is small:

public static bool VerifyTotp(
    string secret,
    string code,
    DateTime utcNow,
    long? lastStep,
    out long matchedStep)
{
    var totp = new Totp(Base32Encoding.ToBytes(secret));

    return totp.VerifyTotp(
        utcNow,
        code,
        out matchedStep,
        new VerificationWindow(previous: 1, future: 1))
        && (!lastStep.HasValue || matchedStep > lastStep.Value);
}

The small window allows for a little difference between the phone's clock and the server's clock. It is not a reason to ignore clock settings, or to accept codes from far into the past.

The last line is easy to overlook. A code may still be within its time window after it has already been used. I store the last accepted time step and require the next accepted step to be newer.

That value lives in the database, so restarting the application does not erase it. The surrounding verification runs inside a database transaction with a lock on the user's row. This matters when two requests arrive together: a check in memory, on its own, would not be enough.

After a successful check, the application marks the challenge as used and creates the admin sign-in ticket. Expired challenges, used challenges, and challenges from a different browser are rejected. Failed attempts are limited as well.

Recovery codes are part of the feature

A phone can be lost or replaced. Without a recovery path, the second factor can become a way to lock myself out of my own website.

The application generates recovery codes after setup and shows them once. I need to keep them somewhere private, separate from the phone I might lose.

On the server, these randomly generated codes are stored as hashes. When one is accepted, it is marked as used. It cannot be used again. Replacing the recovery codes invalidates the old set.

The recovery route still starts with the password check. A recovery code replaces the authenticator code, not both steps. After using recovery, I can generate a fresh set of codes.

The session still needs care

After both checks, this website uses an HTTP-only authentication cookie. Browser JavaScript does not need to read it.

The cookie is sent over HTTPS, uses SameSite=Strict, and has a fixed expiry. Requests that change content also go through anti-forgery checks. The second factor adds another check at sign-in; it does not replace those protections.

It also does not make sign-in phishing-proof. A fake website can still ask someone to enter a current code. Passkeys are worth considering when choosing an authentication approach for a new application.

What I would test before calling it finished

The successful sign-in is only one path. The cases I care about include:

  • A correct password without a code must not open the admin area.
  • A wrong or expired code must be rejected.
  • A code that was just accepted must not work again.
  • Two requests using the same code must not both succeed.
  • A challenge must not work after it expires or has been used.
  • A recovery code must work only once.
  • Refreshing the page must not turn an unfinished sign-in into a full session.

The interface should be straightforward too: a clear code field, useful errors, and a visible way to use a recovery code. There is no need to make the person signing in understand the internals.

For me, the useful lesson was that generating six digits is the small part. The work is in deciding when access is granted, keeping setup separate from normal sign-in, and handling the ways a sign-in can fail.

The result is still a simple daily routine: password, authenticator code, then back to managing the website.

Further reading

Bujar Ademi

Written by

Bujar Ademi

Senior .NET Developer & Team Lead

← All articles