Building a Passwordless Authentication Library for Umbraco — Part 2: Designing the Core Package
In Part 1 I covered what the library is, why I built it, and what's coming in the series. This time I want to dig into the Core package. It took a while to identify what would belong in the Core package as when I started I didn't have a full understanding of where I could separate the different approaches.
The Problem With Shared Infrastructure
Imagine you're building a library suite with multiple independently-installable packages, and they all need to share some infrastructure. Token storage, rate limiting, the sign-in service, for example. These things don't belong in any one add-on, they're genuinely shared between each of the main packages.
One approach is to make the host register Core explicitly:
builder.CreateUmbracoBuilder()
.AddPasswordlessCore() // host must remember this
.AddPasswordlessMagicLink()
.Build();
That's fine, but it's friction and I don't like it. If you forget AddPasswordlessCore() you get a runtime error, probably while a member is trying to sign in.
I had to look for a way to allow the core services to be registered via the main packages, but at the same time prevent them from being registered twice (or more) if you were installing multiple Passwordless options.
The Marker Sentinel Pattern
The approach I decided on (thanks to some help with Claude) is what I've been calling the marker sentinel pattern. When Core registers its services, it also registers a tiny private marker class:
internal static class PasswordlessCoreServiceCollectionExtensions
{
public static IServiceCollection AddPasswordlessCoreOnce(this IServiceCollection services)
{
if (services.Any(d => d.ServiceType == typeof(CoreServicesMarker)))
return services; // already registered, bail out
services.AddSingleton<CoreServicesMarker>();
// ... register rate limiter, token store, sign-in service, etc.
return services;
}
private sealed class CoreServicesMarker { }
}
Each add-on calls AddPasswordlessCoreOnce() as its first line. First one through registers everything; subsequent calls see the marker and return early. The end user of the main packages, never needs to think about it. Instead they just call AddPasswordlessMagicLink() and Core is there.
This is the same pattern Umbraco uses internally in several places - which I didn't know untill Claude pointed it out to me. I like this apprach because it's clean and it's hard to misuse.
What Lives in Core
As I built the suite of packages, I had a nagging question - is there common functionality? The answer was yes, as I'm sure you can imagine, and this helped guide the rule if it's used by more than one add-on, it lives in Core. If it's specific to one auth method, it stays in that add-on's package.
Now the packages have reached release state, I've gained a fairly clear set of Core responsibilities:
Rate Limiting
The IPasswordlessRateLimiter interface gives add-on controllers a consistent API for rate limiting without caring about the underlying implementation:
public interface IPasswordlessRateLimiter
{
Task<bool> TryAcquireAsync(string key, TimeSpan window, int limit, CancellationToken ct = default);
}
The default implementation is a fixed-window rate limiter backed by a ConcurrentDictionary. Buckets are keyed by {key}:{epoch} where the epoch is just DateTimeOffset.UtcNow.ToUnixTimeSeconds() / windowSeconds. This gives you a new bucket per window, without any cleanup timer needed for counting. Expired buckets do get pruned in the background every five minutes to keep memory tidy.
The following is a Claude explination of tacking Atomicity/Concurrent Load issues in the rate limiter:
The important thing here is atomicity. Naively you might write something like:
var count = _buckets.GetValueOrDefault(key, 0);
_buckets[key] = count + 1;
return count < limit;
But that's a classic check-then-act race. Under concurrent load two requests can both read 0, both write 1, and both slip under the limit. The correct version uses ConcurrentDictionary.GetOrAdd and Interlocked.Increment together so the increment is atomic:
var counter = _buckets.GetOrAdd(key, _ => new Counter());
var count = Interlocked.Increment(ref counter.Value);
return count <= limit;
It's a subtle distinction but it matters and it's talked about again in Part 6 when I talk about security more broadly. But back to the features of the Core package.
Single-Use Token Store
The token stores for Magic links and OTP codes are both single-use, to aid with this I ended up with an interface to help. The ISingleUseTokenStore handles this:
public interface ISingleUseTokenStore
{
Task<bool> TryMarkUsedAsync(string tokenHash, TimeSpan ttl, CancellationToken ct = default);
}
The interface deliberately takes a hash of the token, not the token itself. The raw token isn't stored anywhere, only a SHA-256 hash of it. In the future the hashing mechansim may get replaced, or I'll make it configurable. TryMarkUsedAsync should return true if this is the first time we've seen this hash and false if it's been used before.
There are two implementations: InMemorySingleUseTokenStore for single-node deployments, and DistributedCacheSingleUseTokenStore for load-balanced environments. I'll come back to the trade-offs between them in Part 6.
Sign-In Orchestration
The IPasswordlessSignInService is a thin wrapper around Umbraco's IMemberSignInManager, but it does one important extra thing: it rotates the security stamp before signing in:
public async Task SignInAndRotateAsync(MemberIdentityUser member, ...)
{
await _userManager.UpdateSecurityStampAsync(member);
member = await _userManager.FindByIdAsync(member.Id) ?? throw new InvalidOperationException();
await _signInManager.SignInAsync(member, isPersistent, authenticationMethod);
}
Claudes explination for rotating the security stamp
Why rotate the security stamp? Because ASP.NET Core Identity uses it as a session invalidation mechanism. When the stamp changes, all existing session cookies for that member become invalid. This means the moment someone successfully authenticates via passwordless, any previously-active sessions are killed — which is exactly the right behaviour. You don't want someone who had a leaked session cookie to stay signed in after a new authentication event.
Member Lookup
IMemberLookupService is a small wrapper around Umbraco's IMemberManager and enforces approval checks:
public async Task<MemberIdentityUser?> FindApprovedAsync(string email)
{
var member = await _memberManager.FindByEmailAsync(email);
if (member is null) return null;
if (!member.IsApproved || member.IsLockedOut) return null;
return member;
}
Locked-out or unapproved members get null back, same as a member that doesn't exist. This is so we don't reveal whether an email address is registered but locked out, more on that in Part 3.
The Extensibility Model
All the Core defaults are registered with TryAdd* methods, which means host projects can replace any of them by registering their own implementation first. Each add-on also exposes a builder that makes substitution explicit:
builder.AddPasswordlessMagicLink(cfg => cfg
.UseNotificationSender<MyCustomEmailSender>()
.UseSingleUseTokenStore<RedisTokenStore>()
);
This is a based on how ASP.NET Core's own services work. You get sensible defaults out of the box, but if you need Redis-backed token storage (good for load balanced/multi-tenant sites) or a custom email provider, you're not fighting the framework to get there.
Service Dependency Map
Putting it all together, here's how the Core services relate to each other:
graph LR
RC[Rate Limit Controller] --> RL[IPasswordlessRateLimiter]
AC[Auth Controller] --> ML[IMemberLookupService]
AC --> SU[ISingleUseTokenStore]
AC --> SI[IPasswordlessSignInService]
ML --> MM[IMemberManager]
SI --> UM[UserManager]
SI --> SM[IMemberSignInManager]
RL --> CD[ConcurrentDictionary]
SU --> DC[IDistributedCache or InMemory]
Nothing in Core knows about magic links, OTP codes, or passkeys. It's all generic infrastructure that happens to be shared.
What This Series Will Cover
- [Part 1] Why I built this, what's in the box, and how it fits together
- [Part 2] The Core package — designing for extensibility (you're here)
- [Part 3] Magic Links — token providers, single-use enforcement, and email templates
- [Part 4] Email OTP — code generation, attempt counters, and the multi-node problem
- [Part 5] WebAuthn/Passkeys — registration and sign-in ceremonies, credential storage
- [Part 6] Security under the hood — timing attacks, constant-time comparison, rate limiting atomicity
Next up is Part 3, where I'll walk through the magic link add-on. It's conceptually simple but there are some interesting security details hiding in the implementation.
If you have questions about any of the Core design decisions, I'm happy to talk through them — find me on the Umbraco Discord as Nik, on Mastodon, or on X.