Identity Base 0.7.4: Unified Lifecycle Hooks and Notification Content Augmentors
Published: November 13, 2025
Reacting to authentication lifecycle events has always required awkward workarounds. You want to send a Slack notification when a user confirms their email, but the authentication library already sends its confirmation email and doesn't expose a hook for what happens next. You want to customize invitation emails per organization—add the inviter's name, include company branding, change the call-to-action text—but the template variables are hardcoded and the email service is sealed.
The typical solution is to fork the authentication service, add your hooks in the right places, and maintain that fork as the upstream library evolves. Or you poll the database for state changes and try to deduplicate events. Both approaches trade maintenance burden for functionality.
Identity Base 0.7.4 eliminates this friction by introducing unified lifecycle listeners with before/after hooks for every user and organization event, plus a notification augmentation pipeline that lets you modify email templates, variables, and delivery channels before messages reach the transport. The result is a first-class extension surface that doesn't require forking or polling.
TL;DR
Unified lifecycle listeners replace a dozen narrow listener interfaces (IUserCreationListener, IPasswordResetListener, etc.) with two consolidated contracts: IUserLifecycleListener and IOrganizationLifecycleListener. Each exposes paired Before*Async and After*Async methods for every event (registration, confirmation, password reset/change, MFA operations, invitations, membership changes, deletion, restore). Before-hooks can veto operations; after-hooks observe completed changes. All methods use default implementations, so you only implement what you need.
Notification content augmentors introduce a pipeline that runs between "decide to send" and "invoke transport." Register INotificationContextAugmentor<TContext> implementations to modify template keys, add variables, or change subject lines for the built-in contexts (EmailConfirmationNotificationContext, PasswordResetNotificationContext, EmailMfaChallengeNotificationContext) before messages reach Mailjet or your chosen sender. Augmentors compose cleanly with lifecycle listeners for comprehensive customization.
Migration path: Legacy listeners continue to work via compatibility shims but are marked obsolete. Consolidate your implementations into unified listeners using the builder's .AddUserLifecycleListener<T>() and .AddOrganizationLifecycleListener<T>() methods.
The Problem with Multiple Narrow Listeners
Previous versions of Identity Base exposed several specific listener interfaces: IUserCreationListener, IUserUpdateListener, IPasswordResetListener, and so on. Each interface handled one narrow event type with a single method signature. This approach created three core issues.
First, implementing comprehensive lifecycle tracking required registering a dozen different listener types. If you wanted to log all user events to an audit system, you had to create separate implementations for creation, update, password reset, email confirmation, MFA enrollment, role changes, and deletion. Each listener duplicated the same audit logging boilerplate with slightly different context parameters.
Second, the listener signatures provided no standardized context. Some passed just the user ID, others included the full user entity, and a few exposed request metadata. Building consistent diagnostics or correlation tracking across events meant normalizing these disparate signatures in every listener implementation.
Third, there was no "before" phase. Listeners fired after operations completed, which meant you couldn't validate preconditions or veto operations based on custom business rules. If you wanted to prevent password resets during a maintenance window or block role escalations for users in specific organizations, you had to modify the endpoint handlers directly.
Notification customization faced similar constraints. Identity Base sends templated emails for confirmation, password reset, MFA challenges, and organization invitations. The template IDs and variables were configured once during startup, and there was no way to modify them per request. If you wanted to send different invitation emails based on the inviter's organization—perhaps add a custom welcome message or change the branding—you had to either maintain separate Identity Base instances per organization or fork the email service and hardcode your branching logic.
How Unified Lifecycle Listeners Work
Identity Base 0.7.4 replaces the narrow listener interfaces with two consolidated contracts: IUserLifecycleListener and IOrganizationLifecycleListener. Each interface exposes paired Before*Async and After*Async methods for every lifecycle event in its domain.
The user lifecycle listener covers registration (both self-service and admin-created), email confirmation, password reset and change, MFA enable/disable/recovery, account lock/unlock, role updates, user deletion, and restoration. The organization lifecycle listener covers organization create/update/archive/restore, invitation created/revoked/accepted, and membership add/update/remove.
Every lifecycle method receives a rich context object that includes the target entity, the acting user (if applicable), tenant information, correlation ID, request metadata, and operation-specific payload data. This standardized context makes it straightforward to build unified audit logging, metrics collection, or external webhooks.
The "before" phase executes sequentially before the core operation. Listeners can inspect the pending change and optionally throw an exception or return a failure result to veto the operation. The lifecycle dispatcher catches the failure, logs it, and surfaces a LifecycleHookRejectedException to the calling endpoint, which then returns an appropriate error response to the client. This gives you fine-grained control over preconditions without modifying endpoint handlers.
The "after" phase runs once the operation has succeeded. Failures in after-hooks are logged but don't roll back the operation by default, though you can configure critical after-hooks to bubble exceptions via LifecycleHookOptions. This design acknowledges that after-hooks typically perform side effects like sending notifications or updating external systems, and you rarely want a failed Slack notification to undo a successful password reset.
All lifecycle methods use default interface implementations that return Task.CompletedTask, which means adding future lifecycle events won't break existing listener implementations. You only implement the methods your application cares about.
Notification Augmentation Pipeline
Notification augmentors solve the template customization problem by introducing a pipeline that executes between the decision to send a notification and the actual delivery. When Identity Base prepares to send an email confirmation, password reset, or email MFA challenge, it first builds a mutable context object containing the template key, subject line, recipient, locale, branding settings, base URLs, and a variables dictionary for template parameters. Additional contexts (like organization invitations) will use the same pipeline as they come online.
This context is passed through a pipeline of registered INotificationContextAugmentor<TContext> implementations. Each augmentor can inspect the context and modify any field: swap the template key, add new variables, change the subject line, or override branding. Augmentors execute in registration order and can signal failure to abort the notification entirely.
After all augmentors run, the final context is translated into the appropriate transport payload—a TemplatedEmail for Mailjet, for example—and sent via the configured sender. The core email services in Identity Base remain unchanged; they simply gained a new step between "decide to send" and "invoke transport."
This design composes cleanly with lifecycle listeners. A common pattern is to use an after-hook to trigger a custom notification (like a welcome email) while using an augmentor to enrich the built-in confirmation email (add a marketing message or change the call-to-action). Both extension points work together without conflicting.
Basic Lifecycle Listener Example
Here's a simple listener that logs all user registration events to an external audit system:
public class AuditUserLifecycleListener : IUserLifecycleListener
{
private readonly IAuditService _auditService;
private readonly ILogger<AuditUserLifecycleListener> _logger;
public AuditUserLifecycleListener(
IAuditService auditService,
ILogger<AuditUserLifecycleListener> logger)
{
_auditService = auditService;
_logger = logger;
}
public async Task AfterSelfRegistrationAsync(
UserLifecycleContext context,
CancellationToken cancellationToken)
{
try
{
await _auditService.LogEventAsync(new AuditEvent
{
Type = "user.registered",
UserId = context.User.Id,
TenantId = context.TenantId,
CorrelationId = context.CorrelationId,
Timestamp = DateTimeOffset.UtcNow,
Metadata = new Dictionary<string, object>
{
["email"] = context.User.Email,
["source"] = "self-registration"
}
}, cancellationToken);
}
catch (Exception ex)
{
// After-hooks log and continue by default
_logger.LogError(ex,
"Failed to log audit event for user {UserId}",
context.User.Id);
}
}
// Implement other lifecycle methods as needed, or leave them
// as default implementations (no-op)
}
Register the listener during startup:
builder.Services.AddIdentityBase()
.AddUserLifecycleListener<AuditUserLifecycleListener>()
.AddMailjetEmailSender(/* ... */);
This listener only implements AfterSelfRegistrationAsync. The interface's default implementations handle all other lifecycle methods, so you don't need to write empty method bodies for events you don't care about.
Veto Pattern with Before Hooks
Before-hooks let you enforce preconditions without modifying endpoint logic. This example prevents password resets during scheduled maintenance windows:
public class MaintenanceWindowListener : IUserLifecycleListener
{
private readonly IMaintenanceScheduleService _scheduleService;
public MaintenanceWindowListener(IMaintenanceScheduleService scheduleService)
{
_scheduleService = scheduleService;
}
public async Task BeforePasswordResetAsync(
UserLifecycleContext context,
CancellationToken cancellationToken)
{
var isMaintenanceActive = await _scheduleService
.IsMaintenanceActiveAsync(cancellationToken);
if (isMaintenanceActive)
{
throw new LifecycleHookRejectedException(
nameof(IUserLifecycleListener.BeforeUserPasswordResetAsync),
"Password resets are temporarily disabled during maintenance. " +
"Please try again after the maintenance window completes.");
}
}
}
When this listener throws LifecycleHookRejectedException, the lifecycle dispatcher catches it and propagates it to the endpoint handler. The endpoint returns a 400 or 503 response (depending on your error mapping) with the exception message. The password reset operation never executes.
Notification Augmentor Example
This augmentor enriches email confirmation messages with deployment-specific branding and onboarding links:
public class ConfirmationBrandingAugmentor
: INotificationContextAugmentor<EmailConfirmationNotificationContext>
{
private readonly ITenantBrandingService _branding;
public ConfirmationBrandingAugmentor(ITenantBrandingService branding)
{
_branding = branding;
}
public async Task<NotificationAugmentorResult> AugmentAsync(
EmailConfirmationNotificationContext context,
CancellationToken cancellationToken)
{
var branding = await _branding.GetBrandingAsync(context.User.TenantId, cancellationToken);
context.Variables["brand_logo_url"] = branding.LogoUrl;
context.Variables["help_center_url"] = branding.HelpCenterUrl;
context.Subject = $"Complete your {branding.ProductName} signup";
return NotificationAugmentorResult.Continue();
}
}
Register the augmentor alongside your lifecycle listeners:
builder.Services.AddIdentityBase()
.AddNotificationContextAugmentor<EmailConfirmationNotificationContext, ConfirmationBrandingAugmentor>()
.AddMailjetEmailSender(/* ... */);
The augmentor runs before the confirmation email is sent, so your Mailjet template can reference {{var:brand_logo_url}} and the other injected variables. If your template doesn't use them, they're safely ignored.
Layering Augmentors
Augmentors can collaborate to fine‑tune a message. This example shortens email MFA challenges for users who prefer minimalist templates:
public class MfaCopyAugmentor
: INotificationContextAugmentor<EmailMfaChallengeNotificationContext>
{
private readonly IUserPreferencesService _preferences;
public MfaCopyAugmentor(IUserPreferencesService preferences)
{
_preferences = preferences;
}
public async Task<NotificationAugmentorResult> AugmentAsync(
EmailMfaChallengeNotificationContext context,
CancellationToken cancellationToken)
{
var prefs = await _preferences.GetPreferencesAsync(context.User.Id, cancellationToken);
if (prefs.MinimalEmailCopy)
{
context.Subject = "Your verification code";
context.Variables["body_intro"] = "Enter this code to continue:";
}
else
{
context.Subject = $"Your {context.User.DisplayName} verification code";
context.Variables["body_intro"] = "Use the code below within 5 minutes to finish signing in.";
}
return NotificationAugmentorResult.Continue();
}
}
Identity Base still dispatches the templated email via the configured ITemplatedEmailSender, but the augmentor gives you control over the copy, subject, and variables before it leaves the pipeline.
Migrating from Legacy Listeners
Existing IUserCreationListener, IUserUpdateListener, IPasswordResetListener, and related interfaces continue to work through compatibility shims. When you register a legacy listener, Identity Base wraps it in an adapter that forwards events to the corresponding lifecycle methods.
However, legacy listeners are marked [Obsolete] and will be removed in a future major version. Migration is straightforward: consolidate your listener implementations into a single IUserLifecycleListener or IOrganizationLifecycleListener class.
Before (legacy listeners):
public class UserCreationListener : IUserCreationListener
{
public Task OnUserCreatedAsync(User user, CancellationToken ct) { /* ... */ }
}
public class UserUpdateListener : IUserUpdateListener
{
public Task OnUserUpdatedAsync(User user, CancellationToken ct) { /* ... */ }
}
public class PasswordResetListener : IPasswordResetListener
{
public Task OnPasswordResetAsync(Guid userId, CancellationToken ct) { /* ... */ }
}
// Register all three
builder.Services.AddIdentityBase()
.AddUserCreationListener<UserCreationListener>()
.AddUserUpdateListener<UserUpdateListener>()
.AddPasswordResetListener<PasswordResetListener>();
After (unified lifecycle listener):
public class UserLifecycleListener : IUserLifecycleListener
{
public Task AfterUserRegisteredAsync(UserLifecycleContext context, CancellationToken ct)
{
// Logic from UserCreationListener
}
public Task AfterUserPasswordResetAsync(UserLifecycleContext context, CancellationToken ct)
{
// Logic from PasswordResetListener
}
public Task AfterUserEmailConfirmedAsync(UserLifecycleContext context, CancellationToken ct)
{
// Logic from confirmation listener
}
// Other methods use default implementations (no-op) unless you need them
}
// Register once
builder.Services.AddIdentityBase()
.AddUserLifecycleListener<UserLifecycleListener>();
The unified approach reduces boilerplate, provides richer context, and opens the door to before-hooks when you need them.
Common Patterns and Edge Cases
Sending Welcome Emails After Confirmation
A frequent request is to send a welcome email after users confirm their email address. With lifecycle listeners, this is straightforward:
public class WelcomeEmailListener : IUserLifecycleListener
{
private readonly ITemplatedEmailSender _emailSender;
private readonly ILogger<WelcomeEmailListener> _logger;
public WelcomeEmailListener(
ITemplatedEmailSender emailSender,
ILogger<WelcomeEmailListener> logger)
{
_emailSender = emailSender;
_logger = logger;
}
public async Task AfterUserEmailConfirmedAsync(
UserLifecycleContext context,
CancellationToken cancellationToken)
{
try
{
await _emailSender.SendAsync(new TemplatedEmail
{
To = context.User.Email,
TemplateId = "welcome-email",
Variables = new Dictionary<string, string>
{
["user_name"] = context.User.FullName ?? context.User.Email,
["dashboard_url"] = "https://app.example.com/dashboard"
}
}, cancellationToken);
_logger.LogInformation(
"Sent welcome email to {Email} after confirmation",
context.User.Email);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send welcome email to {Email}",
context.User.Email);
// Don't throw - we don't want a failed welcome email to fail confirmation
}
}
}
Since this is an after-hook and you catch exceptions, a failed welcome email won't roll back the email confirmation. The user's account remains confirmed, and you can retry the welcome email via a background job if needed.
Enriching Emails with Organization Branding
Use notification augmentors to add organization-specific branding to outgoing confirmation or reset emails:
public class PasswordResetBrandingAugmentor
: INotificationContextAugmentor<PasswordResetNotificationContext>
{
private readonly IOrganizationRepository _organizations;
public PasswordResetBrandingAugmentor(IOrganizationRepository organizations)
{
_organizations = organizations;
}
public async Task<NotificationAugmentorResult> AugmentAsync(
PasswordResetNotificationContext context,
CancellationToken cancellationToken)
{
if (!context.Metadata.TryGetValue("organization_id", out var value) ||
value is not Guid orgId)
{
return NotificationAugmentorResult.Continue();
}
var org = await _organizations.GetByIdAsync(orgId, cancellationToken);
if (org?.BrandingSettings is null)
{
return NotificationAugmentorResult.Continue();
}
context.Variables["logo_url"] = org.BrandingSettings.LogoUrl;
context.Variables["primary_color"] = org.BrandingSettings.PrimaryColor;
context.Subject = $"{org.Name} password reset";
return NotificationAugmentorResult.Continue();
}
}
Register augmentors for each context you need to customize:
builder.Services.AddIdentityBase()
.AddNotificationContextAugmentor<EmailConfirmationNotificationContext, ConfirmationBrandingAugmentor>()
.AddNotificationContextAugmentor<PasswordResetNotificationContext, PasswordResetBrandingAugmentor>();
Now the relevant templates can reference {{var:logo_url}}, {{var:primary_color}}, and other injected values whenever the metadata includes an organization identifier.
Handling Augmentor Failures
If an augmentor throws an exception or returns NotificationAugmentorResult.Fail(...), the notification pipeline stops and the error propagates to the calling service. For critical notifications like password reset emails, this might be acceptable—you don't want to send a broken or malformed email. For less critical notifications, consider catching exceptions within your augmentor and returning success with default values:
public async Task<NotificationAugmentorResult> AugmentAsync(
PasswordResetContext context,
CancellationToken cancellationToken)
{
try
{
var branding = await _brandingService.GetBrandingAsync(
context.TenantId,
cancellationToken);
context.Variables["logo_url"] = branding.LogoUrl;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to load branding for tenant {TenantId}, using defaults",
context.TenantId);
// Use fallback branding
context.Variables["logo_url"] = "https://cdn.example.com/default-logo.png";
}
return NotificationAugmentorResult.Continue();
}
This ensures that branding failures don't prevent password reset emails from being sent, though you log the issue for investigation.
What This Enables
Unified lifecycle listeners and notification augmentors eliminate the need to fork Identity Base or poll for state changes when you need custom behavior. You can now:
- Send welcome emails, Slack notifications, or webhook payloads after any user or organization event without modifying endpoints.
- Enforce business rules via before-hooks—prevent operations during maintenance, block role escalations based on custom policies, or validate preconditions against external systems.
- Customize email templates per organization, locale, or user preference without maintaining separate Identity Base instances.
- Switch notification channels (email to SMS) based on user preferences or operational context.
- Build unified audit logs, metrics dashboards, or compliance reports by observing lifecycle events from a single listener implementation.
The extension surface is first-class and documented, with rich context objects, deterministic ordering, and clear failure semantics. You extend Identity Base by registering listeners and augmentors during startup, not by modifying or wrapping core services.
Troubleshooting Common Issues
Lifecycle Hook Not Executing
If your lifecycle listener isn't being called, check these common issues:
- Registration order: Ensure you've called
.AddUserLifecycleListener<T>()or.AddOrganizationLifecycleListener<T>()on theIdentityBaseBuilderduring startup. - Method signature: Verify that your method signature exactly matches the interface. Parameter names can differ, but types and order must match.
- Default implementation: If you implement
IUserLifecycleListenerbut leave some methods unimplemented, they'll use the default no-op implementation. Make sure you're implementing the specific method you expect to be called.
Enable detailed logging to see lifecycle dispatcher activity:
{
"Logging": {
"LogLevel": {
"IdentityBase.Lifecycle": "Debug"
}
}
}
Augmentor Not Modifying Notification
If your notification augmentor runs but doesn't seem to affect the email:
- Template mismatch: Ensure your email template in Mailjet (or your chosen transport) actually uses the variables you're setting. Variables that aren't referenced in the template are silently ignored.
- Augmentor ordering: If multiple augmentors modify the same variable, the last one wins. Check the registration order in your DI configuration.
- Transport caching: Some transports cache template data. If you recently updated your template but the changes aren't appearing, check for caching issues in your transport configuration.
Before-Hook Exceptions Not Stopping Operations
If a before-hook throws an exception but the operation still completes:
- Exception type: Ensure you're throwing
LifecycleHookRejectedException(or deriving from it). Other exception types may be logged differently depending on your error handling middleware. - After-hook confusion: Verify you're implementing
Before*Async, notAfter*Async. After-hooks run after the operation completes and can't veto it. - Hook options: Check your
LifecycleHookOptionsconfiguration. If exceptions are configured to log-and-continue (uncommon for before-hooks but possible), they won't stop the pipeline.
Version Compatibility
Identity Base 0.7.4 requires .NET and uses default interface implementations, which means no abstract base classes are needed for lifecycle listeners. If you're upgrading from an earlier version:
- Legacy listener interfaces (
IUserCreationListener, etc.) continue to work via compatibility shims, but are marked obsolete. - Notification template configuration keys remain unchanged; augmentors receive the configured defaults and can override them.
- No database migrations are required for this release.
Future releases will introduce additional lifecycle events (e.g., organization transfer, bulk operations) using the same unified listener pattern, so migrating now sets you up for easier adoption of new features.