↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Auth & the edge · No. 52

Finishing Identity: Second Factor, Email Confirmation and Stored Permission Grants

A framework that owns sign-in for two deployed apps cannot add a second factor by adding a step. Here is how a TOTP challenge, an email-confirmation gate and operator-editable permission grants arrive as optional constructor arguments, a marker type and a decorator, so a consumer that adopts none of them observes nothing at all.


There is a moment in every shared authentication library where the interesting work is done and the awkward work is left. The framework already owns sign-in: password verification, brute-force protection, refresh-token rotation with reuse detection, the claims the token carries. Two production apps run their login through that one base class. Then somebody asks for a second factor.

The natural move is to add a step. Query the user's two-factor state, branch, challenge, continue. It is ten lines, and every one of them executes in both deployed apps the moment the package is restored, including the app that never asked for two-factor and has no column to answer the query from.

That is the actual problem this article is about, and it is not a cryptography problem. Four capabilities were missing from the framework's identity story: a second authentication factor, email confirmation, permission grants an operator can change without a deploy, and a user and role administration API. Each of them is easy to write and dangerous to insert. ADR-116 is the record of inserting them anyway, without changing the behavior of anything already deployed.

The series has already covered the pieces these plug into: Article 16 for cross-service token validation, Article 24 for permission-based authorization and its compiled registry, Article 27 for the rotating refresh token and the sign-in pipeline the new claim rides on. This article is about what it costs to extend those without breaking them.

Why it matters

Rubric §11 (Security) is a weight-3 category asking that authentication be centralized and identity flows documented, that authorization be enforced at the right layer rather than in the UI, and that permissions follow least privilege, with "over-broad permissions (admin everywhere), no least privilege" as a named red flag (ArchitectureEvaluationCriteria.md:353-380). MMCA.Common's §11 row sits at Maturity 4 and Implementation 8 (common-ArchitectureScorecard.md:91), and the two capabilities most often missing from the band above it are exactly a step-up factor and an authorization model that can be adjusted without a release.

But there is a second thing at stake, and it is the one that decides the design. A shared framework is a system with users who cannot review your diff. Adding a mandatory query to a sign-in path is not a feature in a library, it is a behavior change in somebody else's production, delivered by a version bump. The framework's own history is the argument: the 1.188.0 release needed an UPGRADING.md section with eight items, one of which (a fallback authorization policy) broke unannotated endpoints (ADR-116:39-42).

So the question is not "how do I implement TOTP." It is "how do I ship TOTP so that the app which does not want it cannot possibly be affected by it."

The MMCA answer: the unadopted path is unreachable, not merely disabled

AuthenticationServiceBase<TUser> gained two constructor parameters, both optional and both defaulted to null: ITwoFactorAuthenticator? twoFactor = null and IOptions<EmailConfirmationSettings>? emailConfirmationSettings = null (AuthenticationServiceBase.cs:83-84). Every existing subclass keeps compiling, because it simply passes fewer arguments, and the documentation on those parameters says what null means: "while it is null the sign-in flow has no second-factor step at all" (:63-69).

That is not the same as a configuration flag, and the difference is the whole point. ChallengeSecondFactorAsync is a two-line expression body: with no authenticator injected it returns TwoFactorOutcome.NotEnrolled outright, and only otherwise delegates to twoFactor.ChallengeAsync (:665-672). An unadopted consumer pays no query, no branch and no allocation. A disabled feature has a code path that runs and decides to do nothing; an absent collaborator has no code path at all. Only the second one is safe to ship to a system you cannot test.

The email gate is built the same way. CheckEmailConfirmed returns success immediately unless the host supplied settings with RequireConfirmedEmail set, and even then refuses only a user whose type implements IEmailConfirmableUser and reports IsEmailConfirmed: false (:641-652). Two independent conditions, both off by default, one of them a type test the app controls.

Where the two gates sit in the login sequence is a decision, not an accident. Both run after the password has been proved: CheckEmailConfirmed at :215, ChallengeSecondFactorAsync at :221, under a comment that gives the reason (:210-213). They return distinct, actionable errors ("confirm your address", "send a code"), and an actionable error is an information leak if an anonymous caller can trigger it. Reaching either gate proves the caller owns the account, so neither is readable by somebody sweeping addresses.

The step-up assertion is a claim, and presence is the whole test

A challenge that is satisfied has to be recorded somewhere the rest of the system can read. The framework stamps an amr-style claim: AuthClaimTypes.MultiFactor is the literal mfa (AuthClaimTypes.cs:62), and its value names the method that satisfied the challenge, otp for a time-based code (:65) or recovery for a single-use recovery code (:68).

Three details make this cheap to adopt.

The claim is stamped without the app's hook participating. LoginAsync arms a private field with the outcome (AuthenticationServiceBase.cs:242) and clears it in a finally (:249); the token is minted through a wrapper that appends the claim to whatever claim set the app's CreateAccessToken returned (:619-632, the arming at :621). No subclass signature changed, so no consumer edits a method to start emitting mfa.

A rotation does not silently drop it. RefreshTokenAsync reads the method back off the presented token (:398) and carries it into the new one, then clears the field (:411). Without that, a step-up would quietly expire at the next refresh and a user who did present a second factor would look like one who never had.

Presence is the assertion, and absence denies. A token for an account with no second factor carries no mfa claim at all, so a request marked IRequiresMfa denies that caller rather than degrading to a role check (AuthClaimTypes.cs:50-62). There is no "this account has no second factor, so let it through" branch anywhere, which is the branch that turns a step-up requirement into a suggestion.

One more small thing worth stealing. The map from outcome to claim value has a default case that returns null with a comment explaining it: an outcome the map has not been taught about means the enum grew and this method did not, and it must not silently mint an mfa claim (AuthenticationServiceBase.cs:679-691). A switch over an enum whose unknown arm asserts an identity fact is a vulnerability waiting for its next contributor.

// Illustrative of the documented shape: the two gates and the optional collaborator.
public abstract class AuthenticationServiceBase<TUser>(
    // ... the eight collaborators every consumer already passes ...
    ITwoFactorAuthenticator? twoFactor = null,
    IOptions<EmailConfirmationSettings>? emailConfirmationSettings = null) : IAuthenticationService
{
    // Both gates run AFTER the password check: they return actionable errors, and reaching
    // them proves the caller owns the account, so neither is readable by an address sweep.
    var confirmationResult = CheckEmailConfirmed(untracked);
    if (confirmationResult.IsFailure)
        return Result.Failure<AuthenticationResponse>(confirmationResult.Errors);

    var secondFactor = await ChallengeSecondFactorAsync(
        untracked.Id, request.TwoFactorCode, cancellationToken);
    if (secondFactor.IsFailure)
        return Result.Failure<AuthenticationResponse>(secondFactor.Errors);

    // With no authenticator injected there is no challenge and no extra query.
    protected virtual Task<Result<TwoFactorOutcome>> ChallengeSecondFactorAsync(
        UserIdentifierType userId, string? code, CancellationToken cancellationToken) =>
        twoFactor is null
            ? Task.FromResult(Result.Success(TwoFactorOutcome.NotEnrolled))
            : twoFactor.ChallengeAsync(userId, code, cancellationToken);

    // Off unless the host set RequireConfirmedEmail AND the app's User implements the contract.
    protected virtual Result CheckEmailConfirmed(TUser untrackedUser)
    {
        if (emailConfirmationSettings?.Value.RequireConfirmedEmail != true)
            return Result.Success();

        return untrackedUser is IEmailConfirmableUser { IsEmailConfirmed: false }
            ? Result.Failure(EmailConfirmationErrors.EmailNotConfirmed(nameof(LoginAsync)))
            : Result.Success();
    }
}

The framework ships no user table, on purpose

ITwoFactorService is stateless cryptography: secrets, provisioning URIs, verification inside a skew window, recovery codes. ITwoFactorStore is the persistence, and the consumer writes it, over its own User aggregate. The DI call is explicit about the split: it "deliberately registers no ITwoFactorStore", because the account's secret and recovery hashes belong to the app's own aggregate (Infrastructure/DependencyInjection.cs:459-464).

This is the same trade the framework already made for password material and for user lookups, and ADR-116 states the reason plainly: the two deployed apps model users differently, and a framework-owned user table would either be too thin to use or force columns an app has no place for (ADR-116:31-38). Every consumer that wants two-factor writes one class. That is deliberate duplication, bought on purpose.

The settings carry the RFC 6238 defaults every authenticator app assumes: six digits, a thirty-second step (TwoFactorSettings.cs:34), one step of tolerated skew on each side, which widens the accepted window to roughly ninety seconds (:46), ten single-use recovery codes (:50), and a twenty-byte shared secret, the RFC 4226 recommendation (:61). They are configurable because a host may have to match an existing enrollment base, not because moving them is a good idea: an authenticator app keyed against one period cannot read codes minted with another (:9-14).

Email confirmation reuses the password-reset design rather than inventing one, down to the record shape, the hashing, the attempt cap and the throttle, under its own key prefix so issuing a confirmation link cannot invalidate an outstanding reset link (ADR-116:66-77). And RequireConfirmedEmail defaults to false, with the reason written next to it: turning it on locks out every existing account whose address was never confirmed, so a host backfills its rows as confirmed first and flips the flag afterwards (EmailConfirmationSettings.cs:47-55). With it off the whole capability is additive: tokens are issued and redeemed, and nothing about who can sign in changes.

Above both sit handler bases the apps derive from rather than reimplement: ConfirmTwoFactorEnrollmentHandlerBase<TCommand> (:29), DisableTwoFactorHandlerBase<TCommand> (:29), RegenerateRecoveryCodesHandlerBase<TCommand> (:30), SendEmailConfirmationHandlerBase<TUser, TCommand> (:40) and ConfirmEmailHandlerBase<TUser, TCommand> (:33).

Stored grants: operator-editable, and structurally unable to deny

Article 24 covered the compiled permission registry: a frozen role-to-permission map, consulted by the CQRS authorization decorators and the [HasPermission] policy. It has one operational weakness. Changing who can do what is a deploy.

Stored grants layer over it. LayeredPermissionRegistry decorates whatever IPermissionRegistry the host already registered and unions the stored set with the compiled one (Infrastructure/DependencyInjection.cs:535-580, the TryDecorate call and its order-tolerant fallback at :566-580). The union is the security property: there is no deny row. A stored edit can only widen a role, so the effective permission set never depends on evaluation order, and a data change can never disable an endpoint the code guarantees. Removing a compiled capability stays a code change (ADR-116:212-215).

Two more decisions hold this up.

The opt-in is a marker type, and it is what maps the table. PermissionGrantModelGate is an empty sealed class that is never injected anywhere (PermissionGrantModelGate.cs:19). ApplicationDbContext resolves it from the root provider with GetService, so its absence reads as "this host did not opt in" rather than failing every context construction (ApplicationDbContext.cs:911), and applies the grant configuration only then, and only in the context instance whose physical source matches the configured DataSourceName (:940, default "Default" at PermissionGrantSettings.cs:31). A host that never calls AddStoredPermissionGrants keeps a byte-identical model, and an opted-in host gets the table in exactly one of its databases.

The authorization read stays synchronous, so the cache is part of the contract. IPermissionRegistry.HasPermission sits on the hot path of every gated request. Loading grants per role on demand inside it means blocking I/O on every such request, so the grants are held as a per-role in-memory snapshot, rebuilt on a timer and immediately after an edit by an invalidator that is the same instance as the cache, "so an edit and the reads that follow it cannot end up looking at two different snapshots" (Infrastructure/DependencyInjection.cs:555-560). CacheSeconds defaults to 300, documented as exactly what it is: the bound on how stale another replica may be after an edit, because invalidation is per process (PermissionGrantSettings.cs:14-23).

A cold cache grants nothing, which is the safe direction, and the compiled layer answers from the first request either way.

Two refusals that are worth more than the feature

StoredPermissionRoleAdministrationService (:45) is the shipped IRoleAdministrationService: it reports each role's compiled and stored permissions and replaces the stored half. The framework can ship this one complete, unlike the user half, because every input is framework-owned: the registry answers what the code grants, the catalog enumerates what the code can grant, and the store holds what data grants (:16-21).

Its write method, SetStoredPermissionsAsync (:120), refuses two things, and both refusals are the kind you only think of after an incident.

It refuses AdministrationPermissions.ManageRoles, outright (:139-146). That is the permission guarding this very surface. Granting it from a row would make access to role administration a matter of data, and deleting that row would lock every operator out of the one screen that could restore it. The check runs before the catalog test and regardless of whether the host compiled the permission in, because the error message has to name the real reason rather than "unknown" (:137-138). A host that wants a role to administer roles compiles that grant in.

It refuses any permission outside the compiled catalog (:148-160). A stored row that no endpoint checks is not a grant, it is a typo, and the difference between "refused at the API" and "written and silently inert" is the difference between a user error and a support ticket six months later.

Everything else about the write is deliberately boring, which is the compliment: a set is a diff rather than a truncate-and-insert, so only changed permissions are written, an unchanged submission writes nothing, and the GrantedAt stamps of untouched rows survive (:32-37, writes at :162-186). The snapshot is invalidated once, after the writes (:187).

// Illustrative of the documented shape: the two refusals on a set.
var desired = new HashSet<string>(permissions.Select(p => p.Trim()), StringComparer.Ordinal);

// Checked BEFORE the catalog test, and regardless of whether the host compiled this
// permission in: the message has to name the real reason rather than "unknown".
if (desired.Contains(AdministrationPermissions.ManageRoles))
    return Result.Failure<RolePermissionsResponse>(Error.Validation(
        "PermissionGrant.ManageRolesMustBeCompiled",
        "It is the permission that guards this surface, so granting it from here would make "
        + "access to role administration a matter of data, and deleting the row would lock "
        + "every operator out of the screen that could restore it.",
        nameof(IRoleAdministrationService), role));

var unknown = desired.Except(catalog.Permissions, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList();
if (unknown.Count > 0)
    return Result.Failure<RolePermissionsResponse>(Error.Validation(
        "PermissionGrant.UnknownPermission",
        "A stored grant may only name a permission the code already declares, so an endpoint "
        + "actually checks it.",
        nameof(IRoleAdministrationService), role));

// A set is a diff, not a truncate-and-insert: unchanged rows keep their GrantedAt stamp.
foreach (var permission in desired.Except(existing, StringComparer.Ordinal))
    await store.GrantAsync(role, permission, changedBy, cancellationToken);

foreach (var permission in existing.Except(desired, StringComparer.Ordinal))
    await store.RevokeAsync(role, permission, cancellationToken);

await invalidator.InvalidateAsync(role, cancellationToken);

The administration API is two bases, gated on capabilities

RolesAdminControllerBase (:61) needs no app-supplied service, because AddStoredPermissionGrants() registers a complete one; an app implements the interface only to change that behavior (:29-33). UsersAdminControllerBase<TUserDto> (:49) takes a consumer-implemented IUserAdministrationService<TUserDto>, because only the app knows its user table, and clamps any requested page to MaxPageSize = 100 (:53).

Both are gated on capabilities, never on role names: [HasPermission(AdministrationPermissions.ManageRoles)] at RolesAdminControllerBase.cs:60 and [HasPermission(AdministrationPermissions.ManageUsers)] at UsersAdminControllerBase.cs:48. That matters more here than anywhere else in an app: an administration surface gated on the string "Admin" is the one place where a role rename becomes a privilege escalation.

The roles base serves three reads and one write, and the third read is the one an editor cannot work without: GetCatalogAsync, routed on the literal catalog (:98), which takes precedence over the {role} template below it, so no role named "catalog" can shadow the endpoint. A consumer routing its subclass at Admin/Roles therefore gets GET Admin/Roles/catalog, and the editor draws from a closed list rather than a free-text box. The closed list is why the "unknown permission" refusal above is a safety net rather than the primary defense.

Trade-offs, honestly

  • Two-factor ships unadopted, and the article will not pretend otherwise. A search across both consumers' Source/ trees this run finds no call to AddTwoFactorAuthentication and no implementation of ITwoFactorStore in either MMCA.ADC or MMCA.Store. The capability is framework code with handler bases, settings, a claim and a challenge, waiting for its first adopter. Email confirmation and stored grants, by contrast, are both live: ADC's Identity service calls AddEmailConfirmation (Program.cs:231) and AddStoredPermissionGrants (:277), Store's calls the same two (Program.cs:183, :220), both apps' User implements IEmailConfirmableUser (ADC User.cs:35, Store User.cs:30), and both route the two controller bases (ADC AdminRolesController.cs:37, UsersAdminController.cs:26; Store AdminRolesController.cs:36, AdminUsersController.cs:28).
  • Registering a service is not the same as changing behavior, and that is a documentation burden. Adopting two-factor takes two steps, not one: the DI call, and then the app passing the resolved ITwoFactorAuthenticator to its base constructor (Infrastructure/DependencyInjection.cs:465-468). Adopting confirmation takes three: the DI call, RequireConfirmedEmail, and the User implementing the contract. Every one of those gaps is a place where somebody believes a feature is on and it is not. The safety is real and the confusion is real, and they are the same property.
  • Optional constructor parameters are a one-way widening. The pattern that makes this source-compatible also means the base constructor's parameter list grows with every optional collaborator, and it is now at ten. This scales to a few more and not to a dozen; at some point the honest refactor is an options object, and that one will not be source-compatible.
  • Stored grants are per-process cached, so a replica can be stale. An edit is live immediately on the replica that served it and reaches the others within CacheSeconds (PermissionGrantSettings.cs:23). A cross-replica push would put a broker on the authorization path. The window it would close is a grant taking effect a few seconds late, never a revoked grant outliving the interval, because the layer can only widen.
  • A stored grant reaches another service only through a token claim. Where modules run as separate services, the host that mints tokens is the one that holds the grants, so a permission granted by a row lands on the holder's next sign-in rather than within CacheSeconds (ADR-116:289-303). That is the same latency a role change has always had, and it is the price of keeping the grant table in one host instead of replicating it.
  • Adopting the table is a migration in the consumer, and it joins the hard-delete allowlist. The grant store hard-deletes a revoked grant, which makes it an exception to the framework's soft-delete default, and consumers running the same fitness rule have to add the type to their own allowlist when they upgrade (ADR-116:339-344).
  • No pages ship. Enrollment (with its QR code and its show-recovery-codes-once screen) and confirmation stay with the consumers; what ships is routeless administration components the app routes, authorizes and links for itself (ADR-116:246-274). Two consumers have not yet wanted the same enrollment page, and the rule is that a component gets promoted when they do.

Apply this even without MMCA

The identity mechanics here are standard. What is worth copying is the shape of the extension.

  1. Make the unadopted path unreachable rather than disabled. An optional collaborator defaulted to null, with a method that short-circuits when it is absent, costs a consumer nothing and cannot misfire. A configuration flag still executes a code path and still has a wrong setting.
  2. Never put a new mandatory step into a sign-in chain you do not operate. If the framework owns login for apps you cannot test, every added step is a change to somebody's production delivered by a version bump. Default the gate off, require two independent conditions to turn it on, and make one of them something the app declares in its own type system.
  3. Run new sign-in gates after the password check. Actionable errors are the point of the feature and an information leak before authentication. Order them so that reaching them proves ownership of the account.
  4. Make the step-up an assertion where presence is the test. Stamp the claim only when a factor was actually presented, carry it through token rotation, and let absence deny. Any "the user has no second factor, so allow" branch converts a requirement into a preference.
  5. Let data widen authority, never remove it. A stored permission layer that can only union with the compiled one has no evaluation-order semantics to get wrong, and no row whose deletion disables an endpoint. Keep revocation of a compiled capability in code.
  6. Refuse the permission that guards the surface, from inside the surface. Any screen that can edit its own access control needs one hard-coded refusal, or its worst outage is a single DELETE with nobody left who can undo it.
  7. Draw the editor from a closed catalog and refuse anything outside it. A grant no endpoint checks is a typo that looks like a grant, and it will be discovered by the person who assumed it worked.
  8. If the read is synchronous, make the cache part of the contract. Say out loud what the staleness bound is and which direction a cold cache fails in. "Grants nothing on a cold cache" is a design statement; discovering it in production is not.

The rule of thumb: in a shared framework, the cost of a feature is paid by the consumers who do not want it. Design so that bill is exactly zero, and adoption becomes a decision instead of an upgrade risk.


What we covered: why a second factor cannot be added to a shared sign-in chain as a step, how AuthenticationServiceBase takes the authenticator and the confirmation settings as optional constructor arguments so the unadopted path is unreachable, where both gates sit relative to the password check and why, how the mfa claim is stamped by a token wrapper and carried through rotation with presence as the whole test, why the framework ships no user table for two-factor material, how stored permission grants union with the compiled registry behind a marker type that maps the table, the two refusals SetStoredPermissionsAsync makes, and how the two administration controller bases gate on capabilities rather than role names.

Next in the series: Article 53, the series index: every pattern in one place, from the Result railway to this one.

MMCA.Common is open source. Star the repo, read the 2-minute ADR-116 behind this pattern, or dotnet add package MMCA.Common.API and build the monolith you can extract later.

Previous: Article 51, "Four ways to do work later: channels, cron, the outbox and durable internal commands." Next: Article 53, the series index, "The MMCA series: every pattern, one place."

Tags: .NET, C Sharp, Authentication, Security, Software Architecture