Skip to content

Building a Passwordless Authentication Library for Umbraco - Part 1: Why?

Passwordless Logo

If you've been building Umbraco member sites for any amount of time, you'll have noticed that the default member authentication story is... fine. It works. But it's very much a classic "use a username and password field" kind of situation. Over the last few years, with phishing attacks on the rise and users reusing the same password across every site they've ever visited - despite the rise in password managers and improved discussions around password security, "fine" isn't really good enough anymore.

In the same time, we've seen more and more systems utilising passwordless login mechanism, from Magic Link emails, to One Time Passwords (via text or email), and more recently PassKey integrations.

So I built HCS Passwordless — a small set of NuGet Packages that make it easy to add passwordless authentication to Umbraco. Three packages that allow you to quickly add Magic links, email OTP codes, and/or WebAuthn passkeys as optional sign-in mechanisms that will work with Umbraco's existing membership system.

This is the first in a series of posts walking through how I built it, the decisions I made along the way, and the security rabbit holes I ended up going down. Whether you want to use the packages or just learn something about authentication in .NET, hopefully there's something useful here.

Why Passwordless?

Seeing passwordless authentication becoming more and more prevalent, I was curious as to what options there were for sites using Umbracos membership provider - not third party SSO integrations. What I found was that you could:

  1. Build it yourself from scratch (time-consuming, easy to get wrong)
  2. Use a third-party auth provider like Auth0 or Azure AD B2C (expensive, adds dependencies, can be overkill for a member-facing Umbraco site)
  3. Skip it and stick with passwords

In the spirit of Umbraco Community, I thought, if we have to build it from scatch, how could someone do it once and make it available to everyone, what would it make sense for that to look like?

What I Ended Up Building

The library is split into four NuGet packages:

Package Purpose
HCS.Passwordless.Core Shared infrastructure — token storage, rate limiting, sign-in
HCS.Passwordless.MagicLink Email magic links
HCS.Passwordless.Otp Email OTP (one-time passcodes)
HCS.Passwordless.WebAuthn FIDO2 passkeys (Touch ID, Face ID, hardware keys)

The package dependency structure is intentional. Core has no knowledge of the add-ons. Each add-on depends on Core and only Core — they have no dependency on each other. That means you install exactly what you need:

graph TD
    ML[MagicLink] --> C[Core]
    OTP[Otp] --> C
    WA[WebAuthn] --> C
    DEMO[Website] --> ML
    DEMO --> OTP
    DEMO --> WA

Magic links only? Install MagicLink ('core' will be installed automatically as a dependency). Want all three? Install them all: MagicLink, OTP, and WebAuthn.

How Registration Works

One of the first design decisions was making registration feel natural in an Umbraco context. Because these are opt in approaches, I've got with extension methods on the Umbraco Builder instead of shipped composers. This gives you greater control over if/when these options are enabled in your project.

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddPasswordlessMagicLink()
    .AddPasswordlessOtp()
    .AddPasswordlessWebAuthn()
    .Build();

Each AddX() call registers Core services internally, so you don't need to think about ordering and you can't accidentally register Core twice. More on how that works in Part 2.

The Configuration Story

All configuration lives under HCS:Authentication in your appsettings.json.

{
  "HCS": {
    "Authentication": {
      "MagicLink": {
        "Enabled": true,
        "TokenLifespan": "00:15:00"
      },
      "Otp": {
        "Enabled": true,
        "CodeLength": 6,
        "MaxAttempts": 5
      },
      "WebAuthn": {
        "Enabled": true,
        "RpId": "example.com",
        "Origins": ["https://example.com"]
      }
    }
  }
}

Configuration is validated at startup so if you've misconfigured something, you'll know about it immediately when the app starts rather than at runtime when a member tries to sign in. Silent errors due to misconfiguration is one of those things that bites you at the worst possible moment, so I've attempted to avoid that.

Key Terms

There are some terms in this series that may need explaining:

Atomic means an operation either completes fully or not at all — there's no observable intermediate state that another thread or process can exploit

What This Series Will Cover

I'm planning to cover:

  1. [Part 1] Why I built this, what's in the box, and how it fits together (you're here)
  2. [Part 2] The Core package — designing for extensibility with the builder pattern
  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
  5. [Part 5] WebAuthn/Passkeys — registration and sign-in ceremonies, credential storage in the Umbraco DB
  6. [Part 6] Security under the hood — timing attacks, constant-time comparison, and rate limiting atomicity

The later posts get progressively more technical. If you just want to use the packages, the first three will probably tell you everything you need to know. If you're interested in the security engineering behind it, posts 4–6 are where things get interesting.

A Note on Umbraco Version

This suite of packages is only going to target LTS versions of Umbraco.


That's Part 1 done, hopefully you've found the intro to this mini-series and the Package Suite interesting that you'll give them a try and read the rest of the series. If you have questions or want to have a look at the code before we get into the detail, the repository can be found on GitHub. In Part 2 I'll dig into the Core package and explain some of the design decisions that make the whole thing tick.

If you've found this useful, or you're already thinking about using the packages, feel free to reach out — I'm on the Umbraco Discord as Nik, on Mastodon, or on X.