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 toAddTwoFactorAuthenticationand no implementation ofITwoFactorStorein 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 callsAddEmailConfirmation(Program.cs:231) andAddStoredPermissionGrants(:277), Store's calls the same two (Program.cs:183,:220), both apps'UserimplementsIEmailConfirmableUser(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
ITwoFactorAuthenticatorto its base constructor (Infrastructure/DependencyInjection.cs:465-468). Adopting confirmation takes three: the DI call,RequireConfirmedEmail, and theUserimplementing 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
DELETEwith nobody left who can undo it. - 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.
- 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.
- Repo: https://github.com/ivanball/MMCA.Common
- This pattern's decision record: ADR-116: Identity Completions Ship as Opt-In Base Classes, Not as Framework Features
- The full 34-category scorecard, §11 included, lives in MMCA.Common: Architecture Scorecard in the docs site.
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