Skip to content

Building a Passwordless Authentication Library for Umbraco — Part 4: Email OTP

Passwordless Logo

In Part 3 we walked through magic links. This time it's email OTP (one-time passcodes). A numeric code that you type in, you're signed in. Simple concept, but there are have been some implementation challenges hiding under the surface.

The first question is obvious: if you already have magic links, why build OTP as well? The honest answer is that not everyone want's magic links. Magic links also require the member to be able to open an email on the same device they're signing in on, or to copy a link between devices without it being mangled. Corporate email clients have a habit of "safety scanning" links by pre-fetching them, which invalidates your single-use token before the member even sees the email. OTP codes try to avoid those problems as you just read the number and type it.

How OTP Works

The flow is similar to magic links but with an explicit code-entry step:

sequenceDiagram
    participant M as Member
    participant S as Your Site
    participant E as Email

    M->>S: POST /auth/otp/request (email)
    S->>S: Rate limit check
    S->>S: Look up member by email
    S->>S: Generate 6-digit code (crypto random)
    S->>S: Hash code, store hash with TTL
    S->>E: Send email with code
    S-->>M: 202 Accepted (always)

    M->>E: Open email, read code
    M->>S: POST /auth/otp/verify (email + code)
    S->>S: Rate limit check
    S->>S: Look up member by email
    S->>S: Increment attempt counter
    S->>S: Hash submitted code, compare with stored hash
    S->>S: Sign in, rotate security stamp
    S-->>M: Redirect to return URL

The main difference from magic links is the attempt counter. With a magic link the token is long and random, brute forcing it is not really feasible. With a 6-digit code there are only a finite number of possibilities, so you absolutely need to limit how many guesses are allowed before the code is invalidated.

Code Generation and Storage

Codes are generated using RandomNumberGenerator.GetInt32(), cryptographically secure but not Random. The code itself is sent to the member in the email. As with Magic Link tokens, what gets stored in the cache is a hash of it and not the plaintext code:

private string GenerateCode(int length)
{
    var max = (int)Math.Pow(10, length);
    var code = RandomNumberGenerator.GetInt32(max);
    return code.ToString($"D{length}");  // zero-padded to correct length
}

private string HashCode(string code, string memberId)
{
    var key = Encoding.UTF8.GetBytes(memberId);
    var data = Encoding.UTF8.GetBytes(code);
    return Convert.ToHexString(HMACSHA256.HashData(key, data));
}

The hash uses HMAC-SHA256 keyed on the member's ID. This means a code generated for one member cannot be replayed against a different member's account, even if two members happened to receive the same code (which is entirely possible given the small space), the stored hashes would differ.

Code length is configurable between 4 and 10 digits with the default set at 6.

The Attempt Counter

This is where OTP gets more interesting than magic links. To attept to prevent brute force attaches, after each failed verification attempt, a counter is incremented. Once the counter hits the configured maximum, the member is locked out for a configurable duration - in the same way that classic Username/Password combinations would lock out an account. On successfull validation the counter is reset.

The IAttemptCounter interface is kept deliberately simple:

public interface IAttemptCounter
{
    Task<(int Count, bool IsLocked)> IncrementAndCheckAsync(
        string memberId,
        string purpose,
        int maxAttempts,
        TimeSpan lockDuration,
        CancellationToken ct = default);

    Task ResetAsync(string memberId, string purpose, CancellationToken ct = default);
}

The in-memory default uses ConcurrentDictionary.AddOrUpdate to ensure the increment is atomic:

var entry = _counters.AddOrUpdate(
    key,
    _ => new AttemptEntry(Count: 1, ExpiresAt: now.Add(lockDuration)),
    (_, existing) => existing with { Count = existing.Count + 1 }
);

return (entry.Count, entry.Count >= maxAttempts);

A member is locked out when Count >= maxAttempts. Locked-out status and the counter both expire after lockDuration, at which point the member can try again.

The Multi-Node Problem

Here's the catch: the in-memory attempt counter doesn't work correctly on load-balanced deployments. Each application instance maintains its own counter. An attacker who knows this can spread their guesses across instances — 5 attempts to node A, 5 attempts to node B, 5 attempts to node C — and never hit the limit on any single node.

This is a genuine limitation and I've been upfront about it in the documentation. For single-node or development use, the default is fine. For production load-balanced sites, you need to swap in a distributed implementation.

Thank you Claude for this example of a Redis backed implementation

The interface was designed to make this straightforward. A Redis-backed implementation just needs INCR (which is atomic at the Redis level) and a TTL-based key:

public class RedisAttemptCounter : IAttemptCounter
{
    public async Task<(int Count, bool IsLocked)> IncrementAndCheckAsync(
        string memberId, string purpose, int maxAttempts, TimeSpan lockDuration,
        CancellationToken ct = default)
    {
        var key = $"pwl:otp:attempts:{memberId}:{purpose}";
        var count = await _redis.StringIncrementAsync(key);
        await _redis.KeyExpireAsync(key, lockDuration, CommandFlags.FireAndForget);
        return ((int)count, count >= maxAttempts);
    }
}

And then swap it in during registration:

builder.AddPasswordlessOtp(cfg => cfg
    .UseAttemptCounter<RedisAttemptCounter>()
);

The library ships with InMemoryAttemptCounter as the default because I didn't want to force a Redis dependency. But the design makes distributed implementations a one-class swap, which felt like the right trade-off.

Verification Flow

The full verification path in OtpController.Verify looks roughly like this:

// 1. Rate limit
if (!await _limiter.TryAcquireAsync($"otp-verify:ip:{ip}", ...))
    return StatusCode(429);

// 2. Look up member (same non-revealing null for not-found, locked, unapproved)
var member = await _lookup.FindApprovedAsync(model.Email);
if (member is null)
{
    await FakeWork.DelayAsync(_options.FakeWorkBudget, ct);
    return Unauthorized();
}

// 3. Increment attempt counter before checking the code
var (count, isLocked) = await _attempts.IncrementAndCheckAsync(
    member.Id, purpose, _otpOptions.MaxAttempts, _otpOptions.LockoutDuration, ct);

if (isLocked)
{
    await FakeWork.DelayAsync(_options.FakeWorkBudget, ct);
    return StatusCode(429);
}

// 4. Verify the code
var isValid = await _userManager.VerifyUserTokenAsync(
    member, TokenProviderNames.Otp, purpose, model.Code);

if (!isValid)
{
    await FakeWork.DelayAsync(_options.FakeWorkBudget, ct);
    return Unauthorized();
}

// 5. Reset counter, sign in
await _attempts.ResetAsync(member.Id, purpose, ct);
await _signIn.SignInAndRotateAsync(member, ct: ct);
return Redirect(ReturnUrlValidator.Sanitize(model.ReturnUrl));

One thing worth noting: the counter is incremented before the code is checked. This means on the final allowed attempt, the counter is already at maxAttempts before we even look at the code. If the code is correct, we reset the counter and sign in. If it's incorrect, we're locked out. This prevents an attacker from knowing they're on their last guess and trying something clever.

Configuration

{
  "HCS": {
    "Authentication": {
      "Otp": {
        "Enabled": true,
        "TokenLifespan": "00:05:00",
        "CodeLength": 6,
        "MaxAttempts": 5,
        "LockoutDuration": "00:15:00"
      }
    }
  }
}

Five-minute code lifespans are tight, but deliberate. Because a six-digit code is much lower entropy than a magic link token a shorter valid window is sensible. Members who haven't entered the code within five minutes will need to request a new one.

What This Series Will Cover

  1. [Part 1] Why I built this, what's in the box, and how it fits together
  2. [Part 2] The Core package — designing for extensibility
  3. [Part 3] Magic Links — token providers, single-use enforcement, and email templates
  4. [Part 4] Email OTP — code generation, attempt counters, and the multi-node problem (you're here)
  5. [Part 5] WebAuthn/Passkeys — registration and sign-in ceremonies, credential storage
  6. [Part 6] Security under the hood — timing attacks, constant-time comparison, rate limiting atomicity

Next up is the most complex part of the library: WebAuthn. Passkeys involve a proper cryptographic ceremony with multiple round-trips between the browser and server, and there's credential storage in the Umbraco database to think about too. It's a bigger topic than magic links or OTP but hopefully also the most interesting.

As always, if anything here prompted a question, I'm on the Umbraco Discord as Nik, on Mastodon, or on X.